Compare commits
19 Commits
c63f3b0e20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 13b8b36c4c | |||
| 01cdd5ab4f | |||
| ff8c39dc75 | |||
| 56fe2a2af3 | |||
| d16f4287ce | |||
| 0bc04dbf6b | |||
| 6e78140103 | |||
| 8c62780dcb | |||
| 2d0a34347b | |||
| 5e37d10300 | |||
| 1e61e34fb8 | |||
| 07dc8d7976 | |||
| ee99ab11c9 | |||
| 0ecf6238de | |||
| 60dc57ddf7 | |||
| a8bb610be7 | |||
| 81e6a5aaa6 | |||
| d2eea9880a | |||
| a11af16346 |
@@ -18,8 +18,9 @@ out
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.log
|
*.log
|
||||||
.env.local
|
#.env
|
||||||
.env*.local
|
#.env.*
|
||||||
|
!.env.example
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
@@ -17,8 +17,10 @@ NEXT_PUBLIC_SENTRY_PROJECT_NAME=example
|
|||||||
CONVEX_SELF_HOSTED_URL=https://api.convex.example.com # convex-backend:3210
|
CONVEX_SELF_HOSTED_URL=https://api.convex.example.com # convex-backend:3210
|
||||||
CONVEX_SELF_HOSTED_ADMIN_KEY= # Generate after hosted on docker
|
CONVEX_SELF_HOSTED_ADMIN_KEY= # Generate after hosted on docker
|
||||||
# Convex Auth
|
# Convex Auth
|
||||||
CONVEX_SITE_URL=https://convex.example.com # convex-backend:3211
|
CONVEX_SITE_URL=http://localhost:3000 # Always localhost:3000 for local dev; update in Convex Dashboard for production
|
||||||
USESEND_API_KEY=
|
USESEND_API_KEY=
|
||||||
|
USESEND_URL=https://usesend.example.com
|
||||||
|
USESEND_FROM_EMAIL=My App <noreply@example.com>
|
||||||
AUTH_AUTHENTIK_ID=
|
AUTH_AUTHENTIK_ID=
|
||||||
AUTH_AUTHENTIK_SECRET=
|
AUTH_AUTHENTIK_SECRET=
|
||||||
AUTH_AUTHENTIK_ISSUER=
|
AUTH_AUTHENTIK_ISSUER=
|
||||||
|
|||||||
287
README.md
287
README.md
@@ -32,15 +32,21 @@ A production-ready Turborepo starter with Next.js, Expo, and self-hosted Convex
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
This is a self-hosted template. The full setup requires a server (home server or VPS)
|
||||||
|
to host the Convex backend and dashboard, and a reverse proxy (nginx-proxy-manager is
|
||||||
|
recommended) to expose them over HTTPS. The Next.js app can run locally in dev mode
|
||||||
|
once the Convex containers are reachable.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Bun](https://bun.sh) (v1.2.19+)
|
- [Bun](https://bun.sh) (v1.2+)
|
||||||
- [Docker](https://www.docker.com/) & Docker Compose (for self-hosted Convex)
|
- [Docker](https://www.docker.com/) & Docker Compose (for self-hosted Convex)
|
||||||
- Node.js 22.20.0+ (for compatibility)
|
- Node.js 22+ (for compatibility)
|
||||||
|
- A running nginx-proxy-manager instance (or similar reverse proxy) to expose Convex over HTTPS
|
||||||
|
|
||||||
### Development Setup
|
---
|
||||||
|
|
||||||
#### 1. Clone & Install
|
### Step 1 — Clone & Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.gbrown.org/gib/convex-monorepo
|
git clone https://git.gbrown.org/gib/convex-monorepo
|
||||||
@@ -48,88 +54,152 @@ cd convex-monorepo
|
|||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Configure Environment Variables
|
If you're using this as a template for a new project, remove the existing remote and
|
||||||
|
add your own:
|
||||||
Create a `.env` file in the project root with the following variables:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Convex Backend (Self-Hosted)
|
git remote remove origin
|
||||||
CONVEX_SELF_HOSTED_URL=https://api.convex.example.com
|
git remote add origin https://your-git-host.com/your/new-repo.git
|
||||||
CONVEX_SELF_HOSTED_ADMIN_KEY=<generated>
|
|
||||||
CONVEX_SITE_URL=https://convex.example.com
|
|
||||||
|
|
||||||
# Next.js Public
|
|
||||||
NEXT_PUBLIC_CONVEX_URL=https://api.convex.example.com
|
|
||||||
NEXT_PUBLIC_SITE_URL=https://example.com
|
|
||||||
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.example.com
|
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
|
||||||
NEXT_PUBLIC_SENTRY_URL=
|
|
||||||
NEXT_PUBLIC_SENTRY_ORG=
|
|
||||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME=
|
|
||||||
|
|
||||||
# Server-side
|
|
||||||
SENTRY_AUTH_TOKEN=
|
|
||||||
|
|
||||||
# Auth (will be synced to Convex)
|
|
||||||
AUTH_AUTHENTIK_ID=
|
|
||||||
AUTH_AUTHENTIK_SECRET=
|
|
||||||
AUTH_AUTHENTIK_ISSUER=
|
|
||||||
USESEND_API_KEY=
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**For local development:** Use `http://localhost:3210` for Convex URLs.
|
---
|
||||||
|
|
||||||
#### 3. Configure Docker Environment
|
### Step 2 — Configure the Docker Environment
|
||||||
|
|
||||||
Check and update environment variables in `docker/.env` for your deployment:
|
The `docker/` directory contains everything needed to run the Convex backend and the
|
||||||
|
Next.js app in production.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd docker/
|
cd docker/
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your configuration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 4. Start Self-Hosted Convex
|
Edit `docker/.env` and fill in your values. The variables below the Next.js app section
|
||||||
|
control the Convex containers — you'll need to choose:
|
||||||
|
|
||||||
Spin up the Convex backend and dashboard:
|
- `INSTANCE_NAME` — a unique name for your Convex instance
|
||||||
|
- `INSTANCE_SECRET` — a secret string (generate something random)
|
||||||
|
- `CONVEX_CLOUD_ORIGIN` — the public HTTPS URL for your Convex backend API (e.g. `https://api.convex.example.com`)
|
||||||
|
- `CONVEX_SITE_ORIGIN` — the public HTTPS URL for Convex Auth HTTP routes (e.g. `https://api.convex.example.com`)
|
||||||
|
- `NEXT_PUBLIC_DEPLOYMENT_URL` — the URL for the Convex dashboard (e.g. `https://dashboard.convex.example.com`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Start the Convex Containers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd docker/
|
cd docker/
|
||||||
docker compose up -d convex-backend convex-dashboard
|
sudo docker compose up -d convex-backend convex-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
**Services:**
|
Wait a moment for `convex-backend` to pass its health check, then verify both
|
||||||
|
containers are running:
|
||||||
|
|
||||||
- **Backend:** http://localhost:3210
|
```bash
|
||||||
- **Dashboard:** http://localhost:6791
|
sudo docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
#### 5. Generate Auth Keys & Sync Environment Variables
|
Reverse-proxy the two Convex services through nginx-proxy-manager (or your preferred
|
||||||
|
proxy) to the URLs you chose in Step 2. Both must be reachable over HTTPS before you
|
||||||
|
can proceed.
|
||||||
|
|
||||||
Generate JWT keys for Convex Auth:
|
---
|
||||||
|
|
||||||
|
### Step 4 — Generate the Convex Admin Key
|
||||||
|
|
||||||
|
With the backend container running, generate the admin key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker/
|
||||||
|
./generate_convex_admin_key
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the printed key — you'll need it as `CONVEX_SELF_HOSTED_ADMIN_KEY` in the root
|
||||||
|
`.env` file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Configure Root Environment Variables
|
||||||
|
|
||||||
|
Create the root `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill out all values in `/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Next.js
|
||||||
|
NODE_ENV=development
|
||||||
|
SENTRY_AUTH_TOKEN= # From your self-hosted Sentry
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://example.com
|
||||||
|
NEXT_PUBLIC_CONVEX_URL=https://api.convex.example.com
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_URL=https://plausible.example.com
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
NEXT_PUBLIC_SENTRY_URL=https://sentry.example.com
|
||||||
|
NEXT_PUBLIC_SENTRY_ORG=sentry
|
||||||
|
NEXT_PUBLIC_SENTRY_PROJECT_NAME=my-project
|
||||||
|
|
||||||
|
# Convex
|
||||||
|
CONVEX_SELF_HOSTED_URL=https://api.convex.example.com
|
||||||
|
CONVEX_SELF_HOSTED_ADMIN_KEY= # From Step 4
|
||||||
|
CONVEX_SITE_URL=http://localhost:3000 # Always localhost:3000 for local dev
|
||||||
|
|
||||||
|
# Auth (synced to Convex in Step 6)
|
||||||
|
USESEND_API_KEY=
|
||||||
|
USESEND_URL=https://usesend.example.com
|
||||||
|
USESEND_FROM_EMAIL=My App <noreply@example.com>
|
||||||
|
AUTH_AUTHENTIK_ID=
|
||||||
|
AUTH_AUTHENTIK_SECRET=
|
||||||
|
AUTH_AUTHENTIK_ISSUER=https://auth.example.com/application/o/my-app/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6 — Generate JWT Keys & Sync Environment Variables to Convex
|
||||||
|
|
||||||
|
Generate the RS256 JWT keypair needed for Convex Auth:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packages/backend
|
cd packages/backend
|
||||||
bun run scripts/generateKeys.mjs
|
bun run scripts/generateKeys.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
Sync environment variables to Convex deployment (via CLI or Dashboard):
|
This prints `JWT_PRIVATE_KEY` and `JWKS` values. Sync them to your Convex deployment
|
||||||
|
along with all other backend environment variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packages/backend
|
# From packages/backend/
|
||||||
bun with-env npx convex env set AUTH_AUTHENTIK_ID "your-value"
|
bun with-env npx convex env set JWT_PRIVATE_KEY "your-private-key"
|
||||||
bun with-env npx convex env set AUTH_AUTHENTIK_SECRET "your-value"
|
bun with-env npx convex env set JWKS "your-jwks"
|
||||||
|
bun with-env npx convex env set AUTH_AUTHENTIK_ID "your-client-id"
|
||||||
|
bun with-env npx convex env set AUTH_AUTHENTIK_SECRET "your-client-secret"
|
||||||
bun with-env npx convex env set AUTH_AUTHENTIK_ISSUER "your-issuer-url"
|
bun with-env npx convex env set AUTH_AUTHENTIK_ISSUER "your-issuer-url"
|
||||||
bun with-env npx convex env set USESEND_API_KEY "your-api-key"
|
bun with-env npx convex env set USESEND_API_KEY "your-api-key"
|
||||||
bun with-env npx convex env set CONVEX_SITE_URL "http://localhost:3000"
|
bun with-env npx convex env set USESEND_URL "https://usesend.example.com"
|
||||||
|
bun with-env npx convex env set USESEND_FROM_EMAIL "My App <noreply@example.com>"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important:** For local development, set `CONVEX_SITE_URL` to `http://localhost:3000`.
|
**For production auth to work**, you must also update `CONVEX_SITE_URL` in the Convex
|
||||||
|
Dashboard to your production Next.js URL. Go to
|
||||||
|
`https://dashboard.convex.example.com` → Settings → Environment Variables and set:
|
||||||
|
|
||||||
#### 6. Start Development Server
|
```
|
||||||
|
CONVEX_SITE_URL = https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The root `.env` value of `http://localhost:3000` is correct for local dev and should
|
||||||
|
not be changed — only update it in the Dashboard for production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7 — Start the Development Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From project root
|
# From project root
|
||||||
bun dev:next # Next.js app + Convex backend
|
bun dev:next # Next.js app + Convex backend (most common)
|
||||||
# or
|
# or
|
||||||
bun dev # All apps (Next.js + Expo + Backend)
|
bun dev # All apps (Next.js + Expo + Backend)
|
||||||
```
|
```
|
||||||
@@ -137,7 +207,7 @@ bun dev # All apps (Next.js + Expo + Backend)
|
|||||||
**App URLs:**
|
**App URLs:**
|
||||||
|
|
||||||
- **Next.js:** http://localhost:3000
|
- **Next.js:** http://localhost:3000
|
||||||
- **Convex Dashboard:** http://localhost:6791
|
- **Convex Dashboard:** https://dashboard.convex.example.com
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -193,7 +263,7 @@ convex-monorepo/
|
|||||||
│
|
│
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── backend/ # Convex backend
|
│ ├── backend/ # Convex backend
|
||||||
│ │ ├── convex/ # Convex functions (synced to cloud)
|
│ │ ├── convex/ # Convex functions (synced to deployment)
|
||||||
│ │ ├── scripts/ # Utilities (generateKeys.mjs)
|
│ │ ├── scripts/ # Utilities (generateKeys.mjs)
|
||||||
│ │ └── types/ # Shared types
|
│ │ └── types/ # Shared types
|
||||||
│ └── ui/ # shadcn/ui components
|
│ └── ui/ # shadcn/ui components
|
||||||
@@ -223,16 +293,16 @@ convex-monorepo/
|
|||||||
- **OAuth:** Authentik SSO integration
|
- **OAuth:** Authentik SSO integration
|
||||||
- **Password:** Custom password auth with email verification
|
- **Password:** Custom password auth with email verification
|
||||||
- **OTP:** Email verification via self-hosted UseSend
|
- **OTP:** Email verification via self-hosted UseSend
|
||||||
- **Session Management:** Secure cookie-based sessions
|
- **Session Management:** Secure cookie-based sessions (30-day max age)
|
||||||
|
|
||||||
### Next.js App
|
### Next.js App
|
||||||
|
|
||||||
- **App Router:** Next.js 16 with React Server Components
|
- **App Router:** Next.js 16 with React Server Components
|
||||||
- **Data Preloading:** Server-side data fetching with Convex
|
- **Data Preloading:** SSR data fetching with `preloadQuery` + `usePreloadedQuery`
|
||||||
- **Middleware:** Route protection & authentication
|
- **Middleware:** Route protection & IP-based security (`src/proxy.ts`)
|
||||||
- **Styling:** Tailwind CSS v4 with dark mode
|
- **Styling:** Tailwind CSS v4 with dark mode (OKLCH-based theme)
|
||||||
- **Analytics:** Plausible (privacy-focused)
|
- **Analytics:** Plausible (privacy-focused, proxied through Next.js)
|
||||||
- **Monitoring:** Sentry error tracking
|
- **Monitoring:** Sentry error tracking & performance
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
@@ -244,87 +314,113 @@ convex-monorepo/
|
|||||||
|
|
||||||
### Developer Experience
|
### Developer Experience
|
||||||
|
|
||||||
- **Monorepo:** Turborepo for efficient builds
|
- **Monorepo:** Turborepo for efficient builds and caching
|
||||||
- **Type Safety:** Strict TypeScript throughout
|
- **Type Safety:** Strict TypeScript throughout
|
||||||
- **Code Quality:** ESLint + Prettier with auto-fix
|
- **Code Quality:** ESLint + Prettier with auto-fix
|
||||||
- **Hot Reload:** Fast refresh for all packages
|
- **Hot Reload:** Fast refresh for all packages
|
||||||
- **Catalog Deps:** Centralized version management
|
- **Catalog Deps:** Centralized dependency version management
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Docker (Recommended)
|
### Production Deployment (Docker)
|
||||||
|
|
||||||
Build and deploy with Docker Compose:
|
Once the Convex containers are running (they only need to be started once), deploying
|
||||||
|
a new version of the Next.js app is a two-command workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH onto your server, then:
|
||||||
|
cd docker/
|
||||||
|
|
||||||
|
# Build the new image
|
||||||
|
sudo docker compose build next-app
|
||||||
|
|
||||||
|
# Deploy it
|
||||||
|
sudo docker compose up -d next-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To start all services from scratch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd docker/
|
cd docker/
|
||||||
|
sudo docker compose up -d convex-backend convex-dashboard
|
||||||
# Start all services
|
# Wait for backend health check to pass, then:
|
||||||
docker compose up -d
|
sudo docker compose up -d next-app
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# Stop services
|
|
||||||
docker compose down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Services:**
|
**Services:**
|
||||||
|
|
||||||
- `next-app` - Next.js standalone build
|
- `next-app` — Next.js standalone build
|
||||||
- `convex-backend` - Convex backend (port 3210)
|
- `convex-backend` — Convex backend (port 3210)
|
||||||
- `convex-dashboard` - Admin dashboard (port 6791)
|
- `convex-dashboard` — Admin dashboard (port 6791)
|
||||||
|
|
||||||
**Network:** Uses `nginx-bridge` network (configurable in `compose.yml`).
|
**Network:** Uses `nginx-bridge` Docker network (reverse proxy via nginx-proxy-manager).
|
||||||
|
|
||||||
### Production Checklist
|
### Production Checklist
|
||||||
|
|
||||||
- [ ] Update environment variables in `docker/.env`
|
- [ ] Fill out `docker/.env` with your domain names and secrets
|
||||||
- [ ] Generate `CONVEX_SELF_HOSTED_ADMIN_KEY`
|
- [ ] Start `convex-backend` and `convex-dashboard` containers
|
||||||
- [ ] Configure reverse proxy (Nginx/Traefik)
|
- [ ] Generate and set `CONVEX_SELF_HOSTED_ADMIN_KEY` via `./generate_convex_admin_key`
|
||||||
- [ ] Set up SSL certificates
|
- [ ] Reverse-proxy both Convex services via nginx-proxy-manager with SSL
|
||||||
- [ ] Sync auth environment variables to Convex
|
- [ ] Fill out root `/.env` with all environment variables
|
||||||
- [ ] Configure backup strategy for `docker/data/`
|
- [ ] Generate JWT keys and sync all env vars to Convex (`bun with-env npx convex env set ...`)
|
||||||
- [ ] Test authentication flow
|
- [ ] Update `CONVEX_SITE_URL` in the Convex Dashboard to your production Next.js URL
|
||||||
- [ ] Enable Sentry error tracking
|
- [ ] Build and start the `next-app` container
|
||||||
|
- [ ] Back up `docker/data/` regularly (contains all Convex database data)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- **[AGENTS.md](./AGENTS.md)** - Comprehensive guide for AI agents & developers
|
- **[AGENTS.md](./AGENTS.md)** — Comprehensive guide for AI agents & developers
|
||||||
- **[Convex Docs](https://docs.convex.dev)** - Official Convex documentation
|
- **[Convex Docs](https://docs.convex.dev)** — Official Convex documentation
|
||||||
- **[Turborepo Docs](https://turbo.build/repo/docs)** - Turborepo documentation
|
- **[Turborepo Docs](https://turbo.build/repo/docs)** — Turborepo documentation
|
||||||
- **[Next.js Docs](https://nextjs.org/docs)** - Next.js documentation
|
- **[Next.js Docs](https://nextjs.org/docs)** — Next.js documentation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Backend typecheck shows help message
|
### Backend typecheck shows TypeScript help message
|
||||||
|
|
||||||
This is expected behavior. The backend package follows Convex's structure with only `convex/tsconfig.json` (no root tsconfig). See [AGENTS.md](./AGENTS.md) for details.
|
This is expected behavior. The backend package follows Convex's structure with only
|
||||||
|
`convex/tsconfig.json` (no root tsconfig). Running `bun typecheck` from the repo root
|
||||||
|
will show TypeScript's help text for `@gib/backend` — this is not an error.
|
||||||
|
|
||||||
### Imports from Convex require .js extension
|
### Imports from Convex require `.js` extension
|
||||||
|
|
||||||
The project uses ESM (`"type": "module"`), requiring explicit file extensions:
|
The project uses ESM (`"type": "module"`), which requires explicit file extensions:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ Correct
|
// ✅ Correct
|
||||||
|
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
|
||||||
// ❌ Wrong
|
// ❌ Wrong — will fail at runtime
|
||||||
import { api } from '@gib/backend/convex/_generated/api';
|
import { api } from '@gib/backend/convex/_generated/api';
|
||||||
import { api } from '@gib/backend/convex/_generated/api.js';
|
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker containers won't start
|
### Docker containers won't start
|
||||||
|
|
||||||
1. Check Docker logs: `docker compose logs`
|
1. Check Docker logs: `sudo docker compose logs`
|
||||||
2. Verify environment variables in `docker/.env`
|
2. Verify environment variables in `docker/.env`
|
||||||
3. Ensure ports 3210 and 6791 are available
|
3. Ensure the `nginx-bridge` network exists: `sudo docker network create nginx-bridge`
|
||||||
4. Check network configuration (`nginx-bridge`)
|
4. Check that the required ports (3210, 6791) are not already in use
|
||||||
|
|
||||||
|
### Auth doesn't work in production
|
||||||
|
|
||||||
|
Make sure `CONVEX_SITE_URL` is set to your production Next.js URL in the **Convex
|
||||||
|
Dashboard** (not just in the root `.env` file). The root `.env` should always contain
|
||||||
|
`http://localhost:3000`; the Dashboard must have your production URL.
|
||||||
|
|
||||||
|
### Catalog updates break workspace
|
||||||
|
|
||||||
|
After updating dependencies, if you see `sherif` errors on `bun install`:
|
||||||
|
|
||||||
|
1. Never use `bun update` inside individual package directories
|
||||||
|
2. Edit the version in root `package.json` catalog section instead
|
||||||
|
3. Run `bun install` from the root
|
||||||
|
4. Verify with `bun lint:ws`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -337,6 +433,7 @@ This is a personal monorepo template. Feel free to fork and adapt for your needs
|
|||||||
- Single quotes, trailing commas
|
- Single quotes, trailing commas
|
||||||
- 80 character line width
|
- 80 character line width
|
||||||
- ESLint + Prettier enforced
|
- ESLint + Prettier enforced
|
||||||
|
- `const fn = () => {}` over `function fn()` (strong preference)
|
||||||
- Import order: Types → React → Next → Third-party → @gib → Local
|
- Import order: Types → React → Next → Third-party → @gib → Local
|
||||||
|
|
||||||
Run `bun lint:fix` and `bun format:fix` before committing.
|
Run `bun lint:fix` and `bun format:fix` before committing.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
[["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21"],{"key":"22","value":"23"},{"key":"24","value":"25"},{"key":"26","value":"27"},{"key":"28","value":"29"},{"key":"30","value":"31"},{"key":"32","value":"33"},{"key":"34","value":"35"},{"key":"36","value":"37"},{"key":"38","value":"39"},{"key":"40","value":"41"},{"key":"42","value":"43"},{"key":"44","value":"45"},{"key":"46","value":"47"},{"key":"48","value":"49"},{"key":"50","value":"51"},{"key":"52","value":"53"},{"key":"54","value":"55"},{"key":"56","value":"57"},{"key":"58","value":"59"},{"key":"60","value":"61"},{"key":"62","value":"63"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/package.json",{"size":2249,"mtime":1766222924000,"hash":"64","data":"65"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1768155639000,"hash":"66","data":"67"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1768155639000,"hash":"68","data":"69"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/index.tsx",{"size":5019,"mtime":1768372346938,"hash":"70","data":"71"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/turbo.json",{"size":163,"mtime":1766222924000,"hash":"72","data":"73"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/postcss.config.js",{"size":66,"mtime":1768155639000,"hash":"74","data":"75"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eas.json",{"size":567,"mtime":1766222924000,"hash":"76","data":"77"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1766222924000,"hash":"78","data":"79"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":927,"mtime":1768155639000,"hash":"80","data":"81"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1766222924000,"hash":"82","data":"83"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/auth.ts",{"size":398,"mtime":1768155639000,"hash":"84","data":"85"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":757,"mtime":1768372346967,"hash":"86","data":"87"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/styles.css",{"size":90,"mtime":1768155639000,"hash":"88","data":"89"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1768155639000,"hash":"90","data":"91"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1766222924000,"hash":"92"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eslint.config.mts",{"size":275,"mtime":1768155639000,"hash":"93","data":"94"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/api.tsx",{"size":1326,"mtime":1768155639000,"hash":"95","data":"96"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1768155639000,"hash":"97","data":"98"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1768155639000,"hash":"99","data":"100"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1766222924000,"hash":"101"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/tsconfig.json",{"size":387,"mtime":1766228480000,"hash":"102","data":"103"},"d8763702c14cdc382dcfb84f6f9a068f",{"hashOfOptions":"104"},"11cdbef6afa001cd39bc187041ca6865",{"hashOfOptions":"105"},"dbe97bcde588a81538bbcd6a9befdddd",{"hashOfOptions":"106"},"73c235a66242df70b69394cce29d1ed3",{"hashOfOptions":"107"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"108"},"b7edffce093c4c84092cc93f3dc208ef",{"hashOfOptions":"109"},"a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"110"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"111"},"8e407b4b1b0c0bd9c862a00243344be3",{"hashOfOptions":"112"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"113"},"cecbed1604a530a7cc099fecddddd76c",{"hashOfOptions":"114"},"ead19d73283f9d8e08b55c896c9fd570",{"hashOfOptions":"115"},"52a1d72379b952dd802f47e1865bd0da",{"hashOfOptions":"116"},"1bc3e15a40c117eecc51294886ea9b38",{"hashOfOptions":"117"},"1e8ac0d261e95efb19d290ffcf70ce36","1c1710ce3de3ce02e8054cc3787c8579",{"hashOfOptions":"118"},"5ff899a601102659dcbd2900e415ce8b",{"hashOfOptions":"119"},"dd2007a211e323deabb3f7fa7d16313f",{"hashOfOptions":"120"},"4f49c6df7733f874fbe72b4e20b3092b",{"hashOfOptions":"121"},"863da15dbd856008b7c24077ca746d91","6937fb7370f1e17491df649888d6ecc9",{"hashOfOptions":"122"},"1820601142","1684748001","3531839294","2748941218","956511134","132171752","3925902565","1550174236","2506462393","526883382","4111358426","2953691686","2383171816","2740949298","3787272667","3740930138","4230803759","3315245788","3164486579"]
|
[["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21"],{"key":"22","value":"23"},{"key":"24","value":"25"},{"key":"26","value":"27"},{"key":"28","value":"29"},{"key":"30","value":"31"},{"key":"32","value":"33"},{"key":"34","value":"35"},{"key":"36","value":"37"},{"key":"38","value":"39"},{"key":"40","value":"41"},{"key":"42","value":"43"},{"key":"44","value":"45"},{"key":"46","value":"47"},{"key":"48","value":"49"},{"key":"50","value":"51"},{"key":"52","value":"53"},{"key":"54","value":"55"},{"key":"56","value":"57"},{"key":"58","value":"59"},{"key":"60","value":"61"},{"key":"62","value":"63"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/index.tsx",{"size":1935,"mtime":1774546215689,"hash":"64","data":"65"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/index.ts",{"size":28,"mtime":1768155639000,"hash":"66","data":"67"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-dark.png",{"size":19633,"mtime":1766222924000,"hash":"68"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/postcss.config.js",{"size":65,"mtime":1774546126644,"hash":"69","data":"70"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/post/[id].tsx",{"size":678,"mtime":1774546226606,"hash":"71","data":"72"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/convex.ts",{"size":909,"mtime":1774546187217,"data":"73"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eas.json",{"size":566,"mtime":1774546138669,"hash":"74","data":"75"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/app/_layout.tsx",{"size":836,"mtime":1774546198917,"hash":"76","data":"77"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/styles.css",{"size":89,"mtime":1774546134256,"hash":"78","data":"79"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.cache/.prettiercache",{"size":4929,"mtime":1774544663692},"/home/gib/Documents/Code/convex-monorepo/apps/expo/assets/icon-light.png",{"size":19133,"mtime":1766222924000,"hash":"80"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/package.json",{"size":2229,"mtime":1774546163623,"hash":"81","data":"82"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/turbo.json",{"size":171,"mtime":1774031879321,"hash":"83","data":"84"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/eslint.config.mts",{"size":274,"mtime":1774546113649,"hash":"85","data":"86"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/tsconfig.json",{"size":387,"mtime":1766228480000,"hash":"87","data":"88"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/metro.config.js",{"size":511,"mtime":1768155639000,"hash":"89","data":"90"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/nativewind-env.d.ts",{"size":246,"mtime":1766222924000,"hash":"91","data":"92"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/base-url.ts",{"size":880,"mtime":1768155639000,"hash":"93","data":"94"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/.expo-shared/assets.json",{"size":155,"mtime":1766222924000,"hash":"95","data":"96"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/src/utils/session-store.ts",{"size":272,"mtime":1768155639000,"hash":"97","data":"98"},"/home/gib/Documents/Code/convex-monorepo/apps/expo/app.config.ts",{"size":1333,"mtime":1768155639000,"hash":"99","data":"100"},"73c235a66242df70b69394cce29d1ed3",{"hashOfOptions":"101"},"11cdbef6afa001cd39bc187041ca6865",{"hashOfOptions":"102"},"1e8ac0d261e95efb19d290ffcf70ce36","b7edffce093c4c84092cc93f3dc208ef",{"hashOfOptions":"103"},"ead19d73283f9d8e08b55c896c9fd570",{"hashOfOptions":"104"},{"hashOfOptions":"105"},"a3c1487f8318513ae7c156acc857fde2",{"hashOfOptions":"106"},"8e407b4b1b0c0bd9c862a00243344be3",{"hashOfOptions":"107"},"52a1d72379b952dd802f47e1865bd0da",{"hashOfOptions":"108"},"863da15dbd856008b7c24077ca746d91","d8763702c14cdc382dcfb84f6f9a068f",{"hashOfOptions":"109"},"c7d4dcf839dfeaa02e0407adfd5e47a6",{"hashOfOptions":"110"},"1c1710ce3de3ce02e8054cc3787c8579",{"hashOfOptions":"111"},"6937fb7370f1e17491df649888d6ecc9",{"hashOfOptions":"112"},"dbe97bcde588a81538bbcd6a9befdddd",{"hashOfOptions":"113"},"d4d589c153ac8b5e7bf0fb130a5b5a7d",{"hashOfOptions":"114"},"dd2007a211e323deabb3f7fa7d16313f",{"hashOfOptions":"115"},"0f7f54c7161b8403d3bc42d91f59cd91",{"hashOfOptions":"116"},"1bc3e15a40c117eecc51294886ea9b38",{"hashOfOptions":"117"},"4f49c6df7733f874fbe72b4e20b3092b",{"hashOfOptions":"118"},"3000879843","3103968608","384110377","141502567","1235541372","1050155876","2025343866","4147067111","4228440757","3451484829","4039211292","3318113268","2585374463","45764855","1418614640","2754339483","1950506033","3468872477"]
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"base": {
|
"base": {
|
||||||
"node": "22.12.0",
|
"node": "22.12.0",
|
||||||
"pnpm": "9.15.4",
|
"bun": "1.3.10",
|
||||||
"ios": {
|
"ios": {
|
||||||
"resourceClass": "m-medium"
|
"resourceClass": "m-medium"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { baseConfig } from '@acme/eslint-config/base';
|
|
||||||
import { reactConfig } from '@acme/eslint-config/react';
|
|
||||||
import { defineConfig } from 'eslint/config';
|
import { defineConfig } from 'eslint/config';
|
||||||
|
|
||||||
|
import { baseConfig } from '@gib/eslint-config/base';
|
||||||
|
import { reactConfig } from '@gib/eslint-config/react';
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
{
|
{
|
||||||
ignores: ['.expo/**', 'expo-plugins/**'],
|
ignores: ['.expo/**', 'expo-plugins/**'],
|
||||||
|
|||||||
@@ -16,41 +16,40 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/auth": "catalog:convex",
|
"@convex-dev/auth": "catalog:convex",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
"@gib/backend": "workspace:*",
|
"@gib/backend": "workspace:*",
|
||||||
"@legendapp/list": "^2.0.14",
|
"@legendapp/list": "^2.0.19",
|
||||||
"@react-navigation/bottom-tabs": "^7.6.0",
|
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||||
"@react-navigation/elements": "^2.7.1",
|
"@react-navigation/elements": "^2.9.10",
|
||||||
"@react-navigation/native": "^7.1.19",
|
"@react-navigation/native": "^7.1.33",
|
||||||
"@sentry/react-native": "^7.4.0",
|
"@sentry/react-native": "^7.13.0",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"expo": "~54.0.20",
|
"expo": "~54.0.33",
|
||||||
"expo-apple-authentication": "~8.0.7",
|
"expo-apple-authentication": "~8.0.8",
|
||||||
"expo-constants": "~18.0.10",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-dev-client": "~6.0.16",
|
"expo-dev-client": "~6.0.20",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.11",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.11",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-router": "~6.0.13",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "~15.0.7",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.8",
|
||||||
"expo-system-ui": "~6.0.8",
|
"expo-system-ui": "~6.0.9",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.10",
|
||||||
"nativewind": "5.0.0-preview.2",
|
"nativewind": "5.0.0-preview.2",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
"react-dom": "catalog:react19",
|
"react-dom": "catalog:react19",
|
||||||
"react-native": "~0.81.5",
|
"react-native": "~0.81.6",
|
||||||
"react-native-css": "3.0.1",
|
"react-native-css": "3.0.1",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-reanimated": "~4.1.3",
|
"react-native-reanimated": "~4.1.6",
|
||||||
"react-native-safe-area-context": "~5.6.1",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-web": "~0.21.2",
|
"react-native-web": "~0.21.2",
|
||||||
"react-native-worklets": "~0.5.1",
|
"react-native-worklets": "~0.5.2"
|
||||||
"superjson": "2.2.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gib/eslint-config": "workspace:*",
|
"@gib/eslint-config": "workspace:*",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
module.exports = require('@acme/tailwind-config/postcss-config');
|
module.exports = require('@gib/tailwind-config/postcss-config');
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
import { useColorScheme } from 'react-native';
|
import { useColorScheme } from 'react-native';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { ConvexAuthProvider } from '@convex-dev/auth/react';
|
||||||
|
|
||||||
import { queryClient } from '~/utils/api';
|
import { convex } from '~/utils/convex';
|
||||||
|
|
||||||
import '../styles.css';
|
import '../styles.css';
|
||||||
|
|
||||||
// This is the main layout of the app
|
const RootLayout = () => {
|
||||||
// It wraps your pages with the providers they need
|
|
||||||
export default function RootLayout() {
|
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<ConvexAuthProvider client={convex}>
|
||||||
{/*
|
|
||||||
The Stack component displays the current page.
|
|
||||||
It also allows you to configure your screens
|
|
||||||
*/}
|
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: {
|
headerStyle: {
|
||||||
backgroundColor: '#c03484',
|
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
|
||||||
},
|
},
|
||||||
|
headerTintColor: colorScheme === 'dark' ? '#fafaf9' : '#1c1917',
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
backgroundColor: colorScheme == 'dark' ? '#09090B' : '#FFFFFF',
|
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<StatusBar />
|
<StatusBar style='auto' />
|
||||||
</QueryClientProvider>
|
</ConvexAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default RootLayout;
|
||||||
|
|||||||
@@ -1,172 +1,54 @@
|
|||||||
import { useState } from 'react';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { Pressable, Text, TextInput, View } from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Link, Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { LegendList } from '@legendapp/list';
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useConvexAuth, useQuery } from 'convex/react';
|
||||||
|
|
||||||
import type { RouterOutputs } from '~/utils/api';
|
import { api } from '@gib/backend/convex/_generated/api.js';
|
||||||
import { trpc } from '~/utils/api';
|
|
||||||
import { authClient } from '~/utils/auth';
|
|
||||||
|
|
||||||
function PostCard(props: {
|
const Index = () => {
|
||||||
post: RouterOutputs['post']['all'][number];
|
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||||
onDelete: () => void;
|
const { signOut } = useAuthActions();
|
||||||
}) {
|
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
|
||||||
return (
|
|
||||||
<View className='bg-muted flex flex-row rounded-lg p-4'>
|
|
||||||
<View className='grow'>
|
|
||||||
<Link
|
|
||||||
asChild
|
|
||||||
href={{
|
|
||||||
pathname: '/post/[id]',
|
|
||||||
params: { id: props.post.id },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable className=''>
|
|
||||||
<Text className='text-primary text-xl font-semibold'>
|
|
||||||
{props.post.title}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-foreground mt-2'>{props.post.content}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</Link>
|
|
||||||
</View>
|
|
||||||
<Pressable onPress={props.onDelete}>
|
|
||||||
<Text className='text-primary font-bold uppercase'>Delete</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreatePost() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [content, setContent] = useState('');
|
|
||||||
|
|
||||||
const { mutate, error } = useMutation(
|
|
||||||
trpc.post.create.mutationOptions({
|
|
||||||
async onSuccess() {
|
|
||||||
setTitle('');
|
|
||||||
setContent('');
|
|
||||||
await queryClient.invalidateQueries(trpc.post.all.queryFilter());
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='mt-4 flex gap-2'>
|
<SafeAreaView className='bg-background flex-1'>
|
||||||
<TextInput
|
<Stack.Screen options={{ title: 'Home' }} />
|
||||||
className='border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight'
|
<View className='flex-1 items-center justify-center gap-4 p-6'>
|
||||||
value={title}
|
<Text className='text-foreground text-4xl font-bold'>
|
||||||
onChangeText={setTitle}
|
Convex Monorepo
|
||||||
placeholder='Title'
|
|
||||||
/>
|
|
||||||
{error?.data?.zodError?.fieldErrors.title && (
|
|
||||||
<Text className='text-destructive mb-2'>
|
|
||||||
{error.data.zodError.fieldErrors.title}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<Text className='text-muted-foreground text-center text-base'>
|
||||||
<TextInput
|
Your self-hosted Expo + Convex starter
|
||||||
className='border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight'
|
|
||||||
value={content}
|
|
||||||
onChangeText={setContent}
|
|
||||||
placeholder='Content'
|
|
||||||
/>
|
|
||||||
{error?.data?.zodError?.fieldErrors.content && (
|
|
||||||
<Text className='text-destructive mb-2'>
|
|
||||||
{error.data.zodError.fieldErrors.content}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
<Pressable
|
|
||||||
className='bg-primary flex items-center rounded-sm p-2'
|
|
||||||
onPress={() => {
|
|
||||||
mutate({
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-foreground'>Create</Text>
|
|
||||||
</Pressable>
|
|
||||||
{error?.data?.code === 'UNAUTHORIZED' && (
|
|
||||||
<Text className='text-destructive mt-2'>
|
|
||||||
You need to be logged in to create a post
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileAuth() {
|
{isLoading ? (
|
||||||
const { data: session } = authClient.useSession();
|
<Text className='text-muted-foreground'>Loading...</Text>
|
||||||
|
) : isAuthenticated ? (
|
||||||
return (
|
<View className='w-full gap-3'>
|
||||||
<>
|
<Text className='text-foreground text-center text-lg'>
|
||||||
<Text className='text-foreground pb-2 text-center text-xl font-semibold'>
|
Welcome{user?.name ? `, ${user.name}` : ''}!
|
||||||
{session?.user.name ? `Hello, ${session.user.name}` : 'Not logged in'}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() =>
|
className='bg-primary items-center rounded-md p-3'
|
||||||
session
|
onPress={() => void signOut()}
|
||||||
? authClient.signOut()
|
|
||||||
: authClient.signIn.social({
|
|
||||||
provider: 'discord',
|
|
||||||
callbackURL: '/',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className='bg-primary flex items-center rounded-sm p-2'
|
|
||||||
>
|
>
|
||||||
<Text>{session ? 'Sign Out' : 'Sign In With Discord'}</Text>
|
<Text className='text-primary-foreground font-semibold'>
|
||||||
|
Sign Out
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Index() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const postQuery = useQuery(trpc.post.all.queryOptions());
|
|
||||||
|
|
||||||
const deletePostMutation = useMutation(
|
|
||||||
trpc.post.delete.mutationOptions({
|
|
||||||
onSettled: () =>
|
|
||||||
queryClient.invalidateQueries(trpc.post.all.queryFilter()),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView className='bg-background'>
|
|
||||||
{/* Changes page title visible on the header */}
|
|
||||||
<Stack.Screen options={{ title: 'Home Page' }} />
|
|
||||||
<View className='bg-background h-full w-full p-4'>
|
|
||||||
<Text className='text-foreground pb-2 text-center text-5xl font-bold'>
|
|
||||||
Create <Text className='text-primary'>T3</Text> Turbo
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<MobileAuth />
|
|
||||||
|
|
||||||
<View className='py-2'>
|
|
||||||
<Text className='text-primary font-semibold italic'>
|
|
||||||
Press on a post
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
) : (
|
||||||
<LegendList
|
<View className='w-full gap-3'>
|
||||||
data={postQuery.data ?? []}
|
<Text className='text-muted-foreground text-center'>
|
||||||
estimatedItemSize={20}
|
Sign in to get started
|
||||||
keyExtractor={(item) => item.id}
|
</Text>
|
||||||
ItemSeparatorComponent={() => <View className='h-2' />}
|
{/* Add sign-in UI here — see apps/next/src/app/(auth)/sign-in for patterns */}
|
||||||
renderItem={(p) => (
|
</View>
|
||||||
<PostCard
|
|
||||||
post={p.item}
|
|
||||||
onDelete={() => deletePostMutation.mutate(p.item.id)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
<CreatePost />
|
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Index;
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { SafeAreaView, Text, View } from 'react-native';
|
import { Text, View } from 'react-native';
|
||||||
import { Stack, useGlobalSearchParams } from 'expo-router';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
|
||||||
import { trpc } from '~/utils/api';
|
const Post = () => {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
export default function Post() {
|
|
||||||
const { id } = useGlobalSearchParams<{ id: string }>();
|
|
||||||
const { data } = useQuery(trpc.post.byId.queryOptions({ id }));
|
|
||||||
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className='bg-background'>
|
<SafeAreaView className='bg-background flex-1'>
|
||||||
<Stack.Screen options={{ title: data.title }} />
|
<Stack.Screen options={{ title: 'Post' }} />
|
||||||
<View className='h-full w-full p-4'>
|
<View className='flex-1 p-4'>
|
||||||
<Text className='text-primary py-2 text-3xl font-bold'>
|
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
|
||||||
{data.title}
|
<Text className='text-muted-foreground mt-2'>
|
||||||
|
Implement your post detail screen here using Convex queries.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-foreground py-4'>{data.content}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Post;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'nativewind/theme';
|
@import 'nativewind/theme';
|
||||||
@import '@acme/tailwind-config/theme';
|
@import '@gib/tailwind-config/theme';
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { AppRouter } from '@acme/api';
|
|
||||||
import { QueryClient } from '@tanstack/react-query';
|
|
||||||
import { createTRPCClient, httpBatchLink, loggerLink } from '@trpc/client';
|
|
||||||
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
|
|
||||||
import superjson from 'superjson';
|
|
||||||
|
|
||||||
import { authClient } from './auth';
|
|
||||||
import { getBaseUrl } from './base-url';
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A set of typesafe hooks for consuming your API.
|
|
||||||
*/
|
|
||||||
export const trpc = createTRPCOptionsProxy<AppRouter>({
|
|
||||||
client: createTRPCClient({
|
|
||||||
links: [
|
|
||||||
loggerLink({
|
|
||||||
enabled: (opts) =>
|
|
||||||
process.env.NODE_ENV === 'development' ||
|
|
||||||
(opts.direction === 'down' && opts.result instanceof Error),
|
|
||||||
colorMode: 'ansi',
|
|
||||||
}),
|
|
||||||
httpBatchLink({
|
|
||||||
transformer: superjson,
|
|
||||||
url: `${getBaseUrl()}/api/trpc`,
|
|
||||||
headers() {
|
|
||||||
const headers = new Map<string, string>();
|
|
||||||
headers.set('x-trpc-source', 'expo-react');
|
|
||||||
|
|
||||||
const cookies = authClient.getCookie();
|
|
||||||
if (cookies) {
|
|
||||||
headers.set('Cookie', cookies);
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
queryClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type { RouterInputs, RouterOutputs } from '@acme/api';
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import * as SecureStore from 'expo-secure-store';
|
|
||||||
import { expoClient } from '@better-auth/expo/client';
|
|
||||||
import { createAuthClient } from 'better-auth/react';
|
|
||||||
|
|
||||||
import { getBaseUrl } from './base-url';
|
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
|
||||||
baseURL: getBaseUrl(),
|
|
||||||
plugins: [
|
|
||||||
expoClient({
|
|
||||||
scheme: 'expo',
|
|
||||||
storagePrefix: 'expo',
|
|
||||||
storage: SecureStore,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
26
apps/expo/src/utils/convex.ts
Normal file
26
apps/expo/src/utils/convex.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { ConvexReactClient } from 'convex/react';
|
||||||
|
|
||||||
|
const getConvexUrl = (): string => {
|
||||||
|
// Allow override via Expo extra config (set in app.config.ts for production)
|
||||||
|
const fromConfig = Constants.expoConfig?.extra?.convexUrl as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
if (fromConfig) return fromConfig;
|
||||||
|
|
||||||
|
// Fall back to deriving from the dev server host (same pattern as getBaseUrl)
|
||||||
|
const debuggerHost = Constants.expoConfig?.hostUri;
|
||||||
|
const localhost = debuggerHost?.split(':')[0];
|
||||||
|
|
||||||
|
if (!localhost) {
|
||||||
|
throw new Error(
|
||||||
|
'Could not determine Convex URL. Set extra.convexUrl in app.config.ts for production.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point at the self-hosted Convex backend on the local network
|
||||||
|
// Update this port if your Convex backend runs on a different port
|
||||||
|
return `http://${localhost}:3210`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convex = new ConvexReactClient(getConvexUrl());
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turborepo.com/schema.json",
|
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
|
||||||
"extends": ["//"],
|
"extends": ["//"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": {
|
"dev": {
|
||||||
|
|||||||
1
apps/next/.cache/.eslintcache
Normal file
1
apps/next/.cache/.eslintcache
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
|||||||
import { withSentryConfig } from '@sentry/nextjs';
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
import { createJiti } from 'jiti';
|
||||||
import { withPlausibleProxy } from 'next-plausible';
|
import { withPlausibleProxy } from 'next-plausible';
|
||||||
|
|
||||||
import { env } from './src/env.js';
|
const jiti = createJiti(import.meta.url);
|
||||||
|
await jiti.import('./src/env');
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = withPlausibleProxy({
|
const config = withPlausibleProxy({
|
||||||
customDomain: env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||||
})({
|
})({
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
@@ -30,12 +32,12 @@ const config = withPlausibleProxy({
|
|||||||
const sentryConfig = {
|
const sentryConfig = {
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
org: env.NEXT_PUBLIC_SENTRY_ORG,
|
org: process.env.NEXT_PUBLIC_SENTRY_ORG,
|
||||||
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||||
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL,
|
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||||
authToken: env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
// Only print logs for uploading source maps in CI
|
// Only print logs for uploading source maps in CI
|
||||||
silent: !env.CI,
|
silent: !process.env.CI,
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun with-env next build",
|
"build": "bun with-env next build",
|
||||||
|
"build:env": "bun with-env next build",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "bun with-env next dev --turbo",
|
"dev": "bun with-env next dev --turbo",
|
||||||
"dev:tunnel": "bun with-env next dev --turbo",
|
"dev:tunnel": "bun with-env next dev --turbo",
|
||||||
@@ -18,11 +19,11 @@
|
|||||||
"@convex-dev/auth": "catalog:convex",
|
"@convex-dev/auth": "catalog:convex",
|
||||||
"@gib/backend": "workspace:*",
|
"@gib/backend": "workspace:*",
|
||||||
"@gib/ui": "workspace:*",
|
"@gib/ui": "workspace:*",
|
||||||
"@sentry/nextjs": "^10.22.0",
|
"@sentry/nextjs": "^10.43.0",
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"next": "^16.0.0",
|
"next": "^16.1.7",
|
||||||
"next-plausible": "^3.12.4",
|
"next-plausible": "^3.12.5",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
"react-dom": "catalog:react19",
|
"react-dom": "catalog:react19",
|
||||||
"require-in-the-middle": "^7.5.2",
|
"require-in-the-middle": "^7.5.2",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const Profile = async () => {
|
|||||||
|
|
||||||
{/* Profile Card */}
|
{/* Profile Card */}
|
||||||
<Card className='border-border/40'>
|
<Card className='border-border/40'>
|
||||||
<ProfileHeader preloadedUser={preloadedUser} />
|
<ProfileHeader />
|
||||||
<AvatarUpload preloadedUser={preloadedUser} />
|
<AvatarUpload preloadedUser={preloadedUser} />
|
||||||
<Separator className='my-6' />
|
<Separator className='my-6' />
|
||||||
<UserInfoForm
|
<UserInfoForm
|
||||||
|
|||||||
@@ -252,24 +252,27 @@ const SignIn = () => {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultValue={flow}
|
defaultValue={flow}
|
||||||
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
|
||||||
className='items-center'
|
className='flex-col items-center'
|
||||||
>
|
>
|
||||||
<TabsList className='py-6'>
|
<TabsList>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value='signIn'
|
value='signIn'
|
||||||
className='cursor-pointer p-6 text-2xl font-bold'
|
className='cursor-pointer px-6 py-2 text-2xl font-bold'
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value='signUp'
|
value='signUp'
|
||||||
className='cursor-pointer p-6 text-2xl font-bold'
|
className='cursor-pointer px-6 py-2 text-2xl font-bold'
|
||||||
>
|
>
|
||||||
Sign Up
|
Sign Up
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value='signIn'>
|
<TabsContent
|
||||||
<Card className='bg-card/50 min-w-xs sm:min-w-sm'>
|
value='signIn'
|
||||||
|
className='flex min-h-[560px] flex-row items-center'
|
||||||
|
>
|
||||||
|
<Card className='bg-card/50 min-w-xs py-10 sm:min-w-sm'>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...signInForm}>
|
<Form {...signInForm}>
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useEffect } from 'react';
|
|||||||
import Footer from '@/components/layout/footer';
|
import Footer from '@/components/layout/footer';
|
||||||
import Header from '@/components/layout/header';
|
import Header from '@/components/layout/header';
|
||||||
import { ConvexClientProvider } from '@/components/providers';
|
import { ConvexClientProvider } from '@/components/providers';
|
||||||
|
import { env } from '@/env';
|
||||||
import { generateMetadata } from '@/lib/metadata';
|
import { generateMetadata } from '@/lib/metadata';
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import PlausibleProvider from 'next-plausible';
|
import PlausibleProvider from 'next-plausible';
|
||||||
@@ -45,8 +46,8 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
|||||||
}, [error]);
|
}, [error]);
|
||||||
return (
|
return (
|
||||||
<PlausibleProvider
|
<PlausibleProvider
|
||||||
domain='convexmonorepo.gbrown.org'
|
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
|
||||||
customDomain='https://plausible.gbrown.org'
|
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
||||||
>
|
>
|
||||||
<html lang='en' suppressHydrationWarning>
|
<html lang='en' suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ const RootLayout = ({
|
|||||||
return (
|
return (
|
||||||
<ConvexAuthNextjsServerProvider>
|
<ConvexAuthNextjsServerProvider>
|
||||||
<PlausibleProvider
|
<PlausibleProvider
|
||||||
domain={env.NEXT_PUBLIC_SITE_URL}
|
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
|
||||||
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
||||||
>
|
>
|
||||||
<html lang='en'>
|
<html lang='en' suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import Image from 'next/image';
|
export const CTA = () => (
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Button } from '@gib/ui/button';
|
|
||||||
|
|
||||||
export function CTA() {
|
|
||||||
return (
|
|
||||||
<section className='container mx-auto px-4 py-24'>
|
<section className='container mx-auto px-4 py-24'>
|
||||||
<div className='mx-auto max-w-4xl'>
|
<div className='mx-auto max-w-4xl'>
|
||||||
<div className='border-border/40 from-muted/50 to-muted/30 rounded-2xl border bg-gradient-to-br p-8 text-center md:p-12'>
|
<div className='border-border/40 from-muted/50 to-muted/30 rounded-2xl border bg-linear-to-br p-8 text-center md:p-12'>
|
||||||
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
|
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
|
||||||
Ready to Build Something Amazing?
|
Ready to Build Something Amazing?
|
||||||
</h2>
|
</h2>
|
||||||
@@ -35,4 +29,3 @@ export function CTA() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui';
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
@@ -57,8 +57,7 @@ const features = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Features() {
|
export const Features = () => (
|
||||||
return (
|
|
||||||
<section id='features' className='container mx-auto px-4 py-24'>
|
<section id='features' className='container mx-auto px-4 py-24'>
|
||||||
<div className='mx-auto max-w-6xl'>
|
<div className='mx-auto max-w-6xl'>
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
@@ -67,8 +66,8 @@ export function Features() {
|
|||||||
Everything You Need to Ship Fast
|
Everything You Need to Ship Fast
|
||||||
</h2>
|
</h2>
|
||||||
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
|
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
|
||||||
A complete monorepo template with all the tools and patterns you
|
A complete monorepo template with all the tools and patterns you need
|
||||||
need for production-ready applications.
|
for production-ready applications.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,4 +88,3 @@ export function Features() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ import { Kanit } from 'next/font/google';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Button } from '@gib/ui/button';
|
import { Button } from '@gib/ui';
|
||||||
|
|
||||||
const kanitSans = Kanit({
|
const kanitSans = Kanit({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
weight: ['400', '500', '600', '700'],
|
weight: ['400', '500', '600', '700'],
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Hero() {
|
export const Hero = () => (
|
||||||
return (
|
|
||||||
<section className='container mx-auto px-4 py-24 md:py-32 lg:py-40'>
|
<section className='container mx-auto px-4 py-24 md:py-32 lg:py-40'>
|
||||||
<div className='mx-auto flex max-w-5xl flex-col items-center gap-8 text-center'>
|
<div className='mx-auto flex max-w-5xl flex-col items-center gap-8 text-center'>
|
||||||
{/* Badge */}
|
{/* Badge */}
|
||||||
@@ -31,9 +30,9 @@ export function Hero() {
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className='text-muted-foreground max-w-2xl text-lg md:text-xl'>
|
<p className='text-muted-foreground max-w-2xl text-lg md:text-xl'>
|
||||||
A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship
|
A Turborepo starter with Next.js, Expo, and self-hosted Convex. Ship web
|
||||||
web and mobile apps faster with shared code, type-safe backend, and
|
and mobile apps faster with shared code, type-safe backend, and complete
|
||||||
complete control over your infrastructure.
|
control over your infrastructure.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* CTA Buttons */}
|
{/* CTA Buttons */}
|
||||||
@@ -125,4 +124,3 @@ export function Hero() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ const techStack = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TechStack() {
|
export const TechStack = () => (
|
||||||
return (
|
|
||||||
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
|
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
|
||||||
<div className='container mx-auto px-4 py-24'>
|
<div className='container mx-auto px-4 py-24'>
|
||||||
<div className='mx-auto max-w-6xl'>
|
<div className='mx-auto max-w-6xl'>
|
||||||
@@ -76,4 +75,3 @@ export function TechStack() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { Preloaded } from 'convex/react';
|
|
||||||
import { usePreloadedQuery } from 'convex/react';
|
|
||||||
|
|
||||||
import type { api } from '@gib/backend/convex/_generated/api.js';
|
|
||||||
import { CardDescription, CardHeader, CardTitle } from '@gib/ui';
|
import { CardDescription, CardHeader, CardTitle } from '@gib/ui';
|
||||||
|
|
||||||
interface ProfileCardProps {
|
const ProfileHeader = () => {
|
||||||
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProfileHeader = ({ preloadedUser }: ProfileCardProps) => {
|
|
||||||
const user = usePreloadedQuery(preloadedUser);
|
|
||||||
return (
|
return (
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className='text-xl'>Account Settings</CardTitle>
|
<CardTitle className='text-xl'>Account Settings</CardTitle>
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export const UserInfoForm = ({
|
|||||||
}: UserInfoFormProps) => {
|
}: UserInfoFormProps) => {
|
||||||
const user = usePreloadedQuery(preloadedUser);
|
const user = usePreloadedQuery(preloadedUser);
|
||||||
const userProvider = usePreloadedQuery(preloadedProvider);
|
const userProvider = usePreloadedQuery(preloadedProvider);
|
||||||
|
const providerMap: Record<string, string> = {
|
||||||
|
unknown: 'Provider',
|
||||||
|
authentik: "Gib's Auth",
|
||||||
|
};
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const updateUser = useMutation(api.auth.updateUser);
|
const updateUser = useMutation(api.auth.updateUser);
|
||||||
@@ -137,16 +141,17 @@ export const UserInfoForm = ({
|
|||||||
{...field}
|
{...field}
|
||||||
type='email'
|
type='email'
|
||||||
placeholder='john@example.com'
|
placeholder='john@example.com'
|
||||||
disabled={userProvider !== 'email'}
|
disabled={userProvider !== 'password'}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{userProvider === 'email' ? (
|
{userProvider === 'password' ? (
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Your email address for account notifications
|
Your email address for account notifications
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
) : (
|
) : (
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Email is managed through your {userProvider} account
|
Email is managed through your{' '}
|
||||||
|
{providerMap[userProvider ?? 'unknown']} account
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
)}
|
)}
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function Header(headerProps: ComponentProps<'header'>) {
|
|||||||
alt='Convex Monorepo'
|
alt='Convex Monorepo'
|
||||||
width={50}
|
width={50}
|
||||||
height={50}
|
height={50}
|
||||||
className='invert dark:invert-0'
|
className='w-15 invert dark:invert-0'
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`mb-3 hidden font-extrabold lg:inline lg:text-5xl ${kanitSans.className}`}
|
className={`mb-3 hidden font-extrabold lg:inline lg:text-5xl ${kanitSans.className}`}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import { ConvexReactClient } from 'convex/react';
|
|||||||
|
|
||||||
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
|
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
|
||||||
|
|
||||||
export function ConvexClientProvider({ children }: { children: ReactNode }) {
|
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
|
||||||
return (
|
|
||||||
<ConvexAuthNextjsProvider client={convex}>
|
<ConvexAuthNextjsProvider client={convex}>
|
||||||
{children}
|
{children}
|
||||||
</ConvexAuthNextjsProvider>
|
</ConvexAuthNextjsProvider>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,17 +2,13 @@ import { createEnv } from '@t3-oss/env-nextjs';
|
|||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
shared: {
|
server: {
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(['development', 'production', 'test'])
|
.enum(['development', 'production', 'test'])
|
||||||
.default('development'),
|
.default('development'),
|
||||||
},
|
SKIP_ENV_VALIDATION: z.boolean().default(false),
|
||||||
/**
|
|
||||||
* Specify your server-side environment variables schema here.
|
|
||||||
* This way you can ensure the app isn't built with invalid env vars.
|
|
||||||
*/
|
|
||||||
server: {
|
|
||||||
SENTRY_AUTH_TOKEN: z.string(),
|
SENTRY_AUTH_TOKEN: z.string(),
|
||||||
|
CI: z.boolean().default(false),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,10 +27,11 @@ export const env = createEnv({
|
|||||||
/**
|
/**
|
||||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||||
*/
|
*/
|
||||||
experimental__runtimeEnv: {
|
runtimeEnv: {
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
|
||||||
|
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||||
CI: process.env.CI,
|
CI: process.env.CI,
|
||||||
SITE_URL: process.env.SITE_URL,
|
|
||||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||||
@@ -44,6 +41,6 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME:
|
NEXT_PUBLIC_SENTRY_PROJECT_NAME:
|
||||||
process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
|
||||||
},
|
},
|
||||||
skipValidation:
|
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||||
!!process.env.CI || process.env.npm_lifecycle_event === 'lint',
|
emptyStringAsUndefined: true,
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
import { env } from '@/env';
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
import { env } from './env.js';
|
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
integrations: [
|
integrations: [
|
||||||
@@ -20,7 +19,7 @@ Sentry.init({
|
|||||||
tracesSampleRate: 1,
|
tracesSampleRate: 1,
|
||||||
enableLogs: true,
|
enableLogs: true,
|
||||||
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
||||||
replaysSessionSampleRate: 0.5,
|
replaysSessionSampleRate: 1.0,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
debug: false,
|
debug: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
import { env } from '@/env';
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
import { env } from './env.js';
|
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
tracesSampleRate: 1,
|
tracesSampleRate: 1,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["ES2022", "dom", "dom.iterable"],
|
"lib": ["ES2022", "dom", "dom.iterable"],
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
"types": ["node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
# Next Envrionment Variables
|
# Next Envrionment Variables
|
||||||
NODE_ENV=production
|
|
||||||
NETWORK=nginx-bridge
|
NETWORK=nginx-bridge
|
||||||
NEXT_CONTAINER_NAME=next-app
|
NEXT_CONTAINER_NAME=next-app
|
||||||
NEXT_DOMAIN_NAME=gbrown.org
|
NEXT_DOMAIN_NAME=gbrown.org
|
||||||
# Port is disabled by default as suggested
|
NEXT_PORT=3000
|
||||||
# config is to have reverse proxy on the same
|
NODE_ENV=production
|
||||||
# network so you can just forward to the
|
|
||||||
# port on the internal network.
|
|
||||||
# NEXT_PORT=3000
|
|
||||||
SENTRY_AUTH_TOKEN=
|
SENTRY_AUTH_TOKEN=
|
||||||
NEXT_PUBLIC_SITE_URL=https://gbrown.org
|
NEXT_PUBLIC_SITE_URL=https://gbrown.org
|
||||||
NEXT_PUBLIC_CONVEX_URL=https://api.convex.gbrown.org
|
NEXT_PUBLIC_CONVEX_URL=https://api.convex.gbrown.org
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ ENV NODE_ENV=production
|
|||||||
RUN bun run build --filter=@gib/next
|
RUN bun run build --filter=@gib/next
|
||||||
|
|
||||||
# Runner stage
|
# Runner stage
|
||||||
FROM node:20-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ services:
|
|||||||
dockerfile: ./docker/Dockerfile
|
dockerfile: ./docker/Dockerfile
|
||||||
image: ${NEXT_CONTAINER_NAME}:alpine
|
image: ${NEXT_CONTAINER_NAME}:alpine
|
||||||
container_name: ${NEXT_CONTAINER_NAME}
|
container_name: ${NEXT_CONTAINER_NAME}
|
||||||
env_file: [.env]
|
|
||||||
environment:
|
environment:
|
||||||
- SENTRY_AUTH_TOKEN
|
- NODE_ENV
|
||||||
- NEXT_PUBLIC_SITE_URL
|
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
|
||||||
- NEXT_PUBLIC_CONVEX_URL
|
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
|
||||||
- NEXT_PUBLIC_PLAUSIBLE_URL
|
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${BACKEND_PORT:-3210}}
|
||||||
- NEXT_PUBLIC_SENTRY_DSN
|
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
|
||||||
- NEXT_PUBLIC_SENTRY_URL
|
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
|
||||||
- NEXT_PUBLIC_SENTRY_ORG
|
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
|
||||||
- NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
|
||||||
|
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
|
||||||
hostname: ${NEXT_CONTAINER_NAME}
|
hostname: ${NEXT_CONTAINER_NAME}
|
||||||
domainname: ${NEXT_DOMAIN_NAME}
|
domainname: ${NEXT_DOMAIN_NAME}
|
||||||
networks: ['${NETWORK:-nginx-bridge}']
|
networks: ['${NETWORK:-nginx-bridge}']
|
||||||
@@ -38,7 +38,6 @@ services:
|
|||||||
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
|
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
|
||||||
volumes: [./data:/convex/data]
|
volumes: [./data:/convex/data]
|
||||||
labels: ['com.centurylinklabs.watchtower.enable=true']
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
env_file: ['.env']
|
|
||||||
environment:
|
environment:
|
||||||
- INSTANCE_NAME
|
- INSTANCE_NAME
|
||||||
- INSTANCE_SECRET
|
- INSTANCE_SECRET
|
||||||
@@ -67,7 +66,6 @@ services:
|
|||||||
#user: 1000:1000
|
#user: 1000:1000
|
||||||
#ports: ['${DASHBOARD_PORT:-6791}:6791']
|
#ports: ['${DASHBOARD_PORT:-6791}:6791']
|
||||||
labels: ['com.centurylinklabs.watchtower.enable=true']
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
env_file: [.env]
|
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}}
|
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
26
package.json
26
package.json
@@ -13,17 +13,17 @@
|
|||||||
"catalog": {
|
"catalog": {
|
||||||
"@eslint/js": "^9.38.0",
|
"@eslint/js": "^9.38.0",
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@types/node": "^22.18.12",
|
"@types/node": "^22.19.15",
|
||||||
"eslint": "^9.38.0",
|
"eslint": "^9.39.4",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"catalogs": {
|
"catalogs": {
|
||||||
"convex": {
|
"convex": {
|
||||||
"@convex-dev/auth": "^0.0.81",
|
"@convex-dev/auth": "^0.0.81",
|
||||||
"convex": "^1.28.0"
|
"convex": "^1.33.1"
|
||||||
},
|
},
|
||||||
"react19": {
|
"react19": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
@@ -55,12 +55,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gib/prettier-config": "workspace:",
|
"@gib/prettier-config": "workspace:",
|
||||||
"@turbo/gen": "^2.7.4",
|
"@turbo/gen": "^2.8.20",
|
||||||
"baseline-browser-mapping": "^2.9.14",
|
"baseline-browser-mapping": "^2.10.8",
|
||||||
"dotenv-cli": "^10.0.0",
|
"dotenv-cli": "11.0.0",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"turbo": "^2.7.4",
|
"turbo": "^2.8.20",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"prettier": "@gib/prettier-config"
|
"prettier": "@gib/prettier-config",
|
||||||
|
"trustedDependencies": [
|
||||||
|
"@sentry/cli",
|
||||||
|
"core-js-pure",
|
||||||
|
"esbuild",
|
||||||
|
"sharp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
30
packages/backend/convex/_generated/api.d.ts
vendored
30
packages/backend/convex/_generated/api.d.ts
vendored
@@ -23,14 +23,6 @@ import type {
|
|||||||
FunctionReference,
|
FunctionReference,
|
||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
|
||||||
/**
|
|
||||||
* A utility for referencing Convex functions in your app's API.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```js
|
|
||||||
* const myFunctionReference = api.myModule.myFunction;
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
@@ -41,14 +33,30 @@ declare const fullApi: ApiFromModules<{
|
|||||||
http: typeof http;
|
http: typeof http;
|
||||||
utils: typeof utils;
|
utils: typeof utils;
|
||||||
}>;
|
}>;
|
||||||
declare const fullApiWithMounts: typeof fullApi;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's public API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = api.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export declare const api: FilterApi<
|
export declare const api: FilterApi<
|
||||||
typeof fullApiWithMounts,
|
typeof fullApi,
|
||||||
FunctionReference<any, "public">
|
FunctionReference<any, "public">
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility for referencing Convex functions in your app's internal API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```js
|
||||||
|
* const myFunctionReference = internal.myModule.myFunction;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export declare const internal: FilterApi<
|
export declare const internal: FilterApi<
|
||||||
typeof fullApiWithMounts,
|
typeof fullApi,
|
||||||
FunctionReference<any, "internal">
|
FunctionReference<any, "internal">
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
|
|||||||
* Convex documents are uniquely identified by their `Id`, which is accessible
|
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||||
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||||
*
|
*
|
||||||
* Documents can be loaded using `db.get(id)` in query and mutation functions.
|
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
|
||||||
*
|
*
|
||||||
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||||
* strings when type checking.
|
* strings when type checking.
|
||||||
|
|||||||
16
packages/backend/convex/_generated/server.d.ts
vendored
16
packages/backend/convex/_generated/server.d.ts
vendored
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ActionBuilder,
|
ActionBuilder,
|
||||||
AnyComponents,
|
|
||||||
HttpActionBuilder,
|
HttpActionBuilder,
|
||||||
MutationBuilder,
|
MutationBuilder,
|
||||||
QueryBuilder,
|
QueryBuilder,
|
||||||
@@ -19,15 +18,9 @@ import {
|
|||||||
GenericQueryCtx,
|
GenericQueryCtx,
|
||||||
GenericDatabaseReader,
|
GenericDatabaseReader,
|
||||||
GenericDatabaseWriter,
|
GenericDatabaseWriter,
|
||||||
FunctionReference,
|
|
||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
import type { DataModel } from "./dataModel.js";
|
import type { DataModel } from "./dataModel.js";
|
||||||
|
|
||||||
type GenericCtx =
|
|
||||||
| GenericActionCtx<DataModel>
|
|
||||||
| GenericMutationCtx<DataModel>
|
|
||||||
| GenericQueryCtx<DataModel>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query in this Convex app's public API.
|
* Define a query in this Convex app's public API.
|
||||||
*
|
*
|
||||||
@@ -92,11 +85,12 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
|||||||
/**
|
/**
|
||||||
* Define an HTTP action.
|
* Define an HTTP action.
|
||||||
*
|
*
|
||||||
* This function will be used to respond to HTTP requests received by a Convex
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
* deployment if the requests matches the path and method where this action
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
* is routed. Be sure to route your action in `convex/http.js`.
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
*
|
*
|
||||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
*/
|
*/
|
||||||
export declare const httpAction: HttpActionBuilder;
|
export declare const httpAction: HttpActionBuilder;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
internalActionGeneric,
|
internalActionGeneric,
|
||||||
internalMutationGeneric,
|
internalMutationGeneric,
|
||||||
internalQueryGeneric,
|
internalQueryGeneric,
|
||||||
componentsGeneric,
|
|
||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,10 +80,14 @@ export const action = actionGeneric;
|
|||||||
export const internalAction = internalActionGeneric;
|
export const internalAction = internalActionGeneric;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a Convex HTTP action.
|
* Define an HTTP action.
|
||||||
*
|
*
|
||||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
|
* The wrapped function will be used to respond to HTTP requests received
|
||||||
* as its second.
|
* by a Convex deployment if the requests matches the path and method where
|
||||||
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
|
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||||
|
*
|
||||||
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||||
|
* and a Fetch API `Request` object as its second.
|
||||||
|
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||||
*/
|
*/
|
||||||
export const httpAction = httpActionGeneric;
|
export const httpAction = httpActionGeneric;
|
||||||
|
|||||||
@@ -54,16 +54,6 @@ export const getUser = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getAllUsers = query(async (ctx) => {
|
|
||||||
const users = await ctx.db.query('users').collect();
|
|
||||||
return users ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getAllUserIds = query(async (ctx) => {
|
|
||||||
const users = await ctx.db.query('users').collect();
|
|
||||||
return users.map((u) => u._id);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateUser = mutation({
|
export const updateUser = mutation({
|
||||||
args: {
|
args: {
|
||||||
name: v.optional(v.string()),
|
name: v.optional(v.string()),
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ export const validatePassword = (password: string): boolean => {
|
|||||||
if (
|
if (
|
||||||
password.length < 8 ||
|
password.length < 8 ||
|
||||||
password.length > 100 ||
|
password.length > 100 ||
|
||||||
|
/\s/.test(password) ||
|
||||||
!/\d/.test(password) ||
|
!/\d/.test(password) ||
|
||||||
!/[a-z]/.test(password) ||
|
!/[a-z]/.test(password) ||
|
||||||
!/[A-Z]/.test(password)
|
!/[A-Z]/.test(password) ||
|
||||||
|
!/[\p{P}\p{S}]/u.test(password)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
|
|||||||
id: 'usesend',
|
id: 'usesend',
|
||||||
type: 'email',
|
type: 'email',
|
||||||
name: 'UseSend',
|
name: 'UseSend',
|
||||||
from: 'Study Buddy <admin@techtracker.gbrown.org>',
|
from: process.env.USESEND_FROM_EMAIL ?? 'noreply@example.com',
|
||||||
maxAge: 24 * 60 * 60, // 24 hours
|
maxAge: 24 * 60 * 60, // 24 hours
|
||||||
|
|
||||||
async generateVerificationToken() {
|
async generateVerificationToken() {
|
||||||
@@ -21,13 +21,14 @@ export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async sendVerificationRequest(params) {
|
async sendVerificationRequest(params) {
|
||||||
const { identifier: to, provider, url, theme, token } = params;
|
const { identifier: to, provider, url, token } = params;
|
||||||
//const { host } = new URL(url);
|
// Derive a display name from the site URL, fallback to 'App'
|
||||||
const host = 'TechTracker';
|
const siteUrl = process.env.USESEND_FROM_EMAIL ?? '';
|
||||||
|
const appName = siteUrl.split('@')[1]?.split('.')[0] ?? 'App';
|
||||||
|
|
||||||
const useSend = new UseSend(
|
const useSend = new UseSend(
|
||||||
process.env.USESEND_API_KEY!,
|
process.env.USESEND_API_KEY!,
|
||||||
'https://usesend.gbrown.org',
|
process.env.USESEND_URL!,
|
||||||
);
|
);
|
||||||
|
|
||||||
// For password reset, we want to send the code, not the magic link
|
// For password reset, we want to send the code, not the magic link
|
||||||
@@ -38,8 +39,8 @@ export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
|
|||||||
from: provider.from!,
|
from: provider.from!,
|
||||||
to: [to],
|
to: [to],
|
||||||
subject: isPasswordReset
|
subject: isPasswordReset
|
||||||
? `Reset your password - ${host}`
|
? `Reset your password - ${appName}`
|
||||||
: `Sign in to ${host}`,
|
: `Sign in to ${appName}`,
|
||||||
text: isPasswordReset
|
text: isPasswordReset
|
||||||
? `Your password reset code is ${token}`
|
? `Your password reset code is ${token}`
|
||||||
: `Your sign in code is ${token}`,
|
: `Your sign in code is ${token}`,
|
||||||
|
|||||||
@@ -3,22 +3,12 @@ import { defineSchema, defineTable } from 'convex/server';
|
|||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
const applicationTables = {
|
const applicationTables = {
|
||||||
// Users contains name image & email.
|
/*
|
||||||
// If you would like to save any other information,
|
* Below is the users table definition from authTables
|
||||||
// I would recommend including this profiles table
|
* You can add additional fields here. You can also remove
|
||||||
// where you can include settings & anything else you would like tied to the user.
|
* the users table here & create a 'profiles' table if you
|
||||||
profiles: defineTable({
|
* prefer to keep auth data separate from application data.
|
||||||
userId: v.id('users'),
|
*/
|
||||||
theme_preference: v.optional(v.string()),
|
|
||||||
}).index('userId', ['userId']),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineSchema({
|
|
||||||
...authTables,
|
|
||||||
// Default table for users directly from authTable.
|
|
||||||
// You can extend it if you would like, but it may
|
|
||||||
// be better to just use the profiles table example
|
|
||||||
// below.
|
|
||||||
users: defineTable({
|
users: defineTable({
|
||||||
name: v.optional(v.string()),
|
name: v.optional(v.string()),
|
||||||
image: v.optional(v.string()),
|
image: v.optional(v.string()),
|
||||||
@@ -27,9 +17,18 @@ export default defineSchema({
|
|||||||
phone: v.optional(v.string()),
|
phone: v.optional(v.string()),
|
||||||
phoneVerificationTime: v.optional(v.number()),
|
phoneVerificationTime: v.optional(v.number()),
|
||||||
isAnonymous: v.optional(v.boolean()),
|
isAnonymous: v.optional(v.boolean()),
|
||||||
|
/* Fields below here are custom & not defined in authTables */
|
||||||
|
themePreference: v.optional(
|
||||||
|
v.union(v.literal('light'), v.literal('dark'), v.literal('system')),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.index('email', ['email'])
|
.index('email', ['email'])
|
||||||
.index('name', ['name'])
|
.index('phone', ['phone'])
|
||||||
.index('phone', ['phone']),
|
/* Indexes below here are custom & not defined in authTables */
|
||||||
|
.index('name', ['name']),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineSchema({
|
||||||
|
...authTables,
|
||||||
...applicationTables,
|
...applicationTables,
|
||||||
});
|
});
|
||||||
|
|||||||
1
packages/ui/.cache/.eslintcache
Normal file
1
packages/ui/.cache/.eslintcache
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
10982
packages/ui/.cache/tsbuildinfo.json
Normal file
10982
packages/ui/.cache/tsbuildinfo.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,32 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.tsx",
|
".": "./src/index.tsx",
|
||||||
"./avatar": "./src/avatar.tsx",
|
"./hooks": "./src/index.tsx"
|
||||||
"./based-avatar": "./src/based-avatar.tsx",
|
|
||||||
"./based-progress": "./src/based-progress.tsx",
|
|
||||||
"./button": "./src/button.tsx",
|
|
||||||
"./card": "./src/card.tsx",
|
|
||||||
"./checkbox": "./src/checkbox.tsx",
|
|
||||||
"./drawer": "./src/drawer.tsx",
|
|
||||||
"./dropdown-menu": "./src/dropdown-menu.tsx",
|
|
||||||
"./field": "./src/field.tsx",
|
|
||||||
"./form": "./src/form.tsx",
|
|
||||||
"./image-crop": "./src/shadcn-io/image-crop/index.tsx",
|
|
||||||
"./input": "./src/input.tsx",
|
|
||||||
"./input-otp": "./src/input-otp.tsx",
|
|
||||||
"./label": "./src/label.tsx",
|
|
||||||
"./pagination": "./src/pagination.tsx",
|
|
||||||
"./progress": "./src/progress.tsx",
|
|
||||||
"./scroll-area": "./src/scroll-area.tsx",
|
|
||||||
"./separator": "./src/separator.tsx",
|
|
||||||
"./sonner": "./src/sonner.tsx",
|
|
||||||
"./status-message": "./src/status-message.tsx",
|
|
||||||
"./submit-button": "./src/submit-button.tsx",
|
|
||||||
"./switch": "./src/switch.tsx",
|
|
||||||
"./table": "./src/table.tsx",
|
|
||||||
"./tabs": "./src/tabs.tsx",
|
|
||||||
"./theme": "./src/theme.tsx",
|
|
||||||
"./toast": "./src/toast.tsx"
|
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -37,9 +12,10 @@
|
|||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false",
|
"typecheck": "tsc --noEmit --emitDeclarationOnly false",
|
||||||
"ui-add": "pnpm dlx shadcn@latest add && prettier src --write --list-different"
|
"ui-add": "bunx --bun shadcn@latest add && prettier src --write --list-different"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.3.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -55,12 +31,18 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
"react-image-crop": "^11.0.10",
|
"react-image-crop": "^11.0.10",
|
||||||
|
"react-resizable-panels": "^4",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2"
|
"vaul": "^1.1.2"
|
||||||
|
|||||||
79
packages/ui/src/accordion.tsx
Normal file
79
packages/ui/src/accordion.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
|
import { Accordion as AccordionPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const Accordion = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) => (
|
||||||
|
<AccordionPrimitive.Root
|
||||||
|
data-slot='accordion'
|
||||||
|
className={cn('flex w-full flex-col', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AccordionItem = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot='accordion-item'
|
||||||
|
className={cn('not-last:border-b', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AccordionTrigger = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) => (
|
||||||
|
<AccordionPrimitive.Header className='flex'>
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot='accordion-trigger'
|
||||||
|
className={cn(
|
||||||
|
'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon
|
||||||
|
data-slot='accordion-trigger-icon'
|
||||||
|
className='pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden'
|
||||||
|
/>
|
||||||
|
<ChevronUpIcon
|
||||||
|
data-slot='accordion-trigger-icon'
|
||||||
|
className='pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline'
|
||||||
|
/>
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AccordionContent = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot='accordion-content'
|
||||||
|
className='data-open:animate-accordion-down data-closed:animate-accordion-up overflow-hidden text-sm'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'[&_a]:hover:text-foreground h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
176
packages/ui/src/alert-dialog.tsx
Normal file
176
packages/ui/src/alert-dialog.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { Button, cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const AlertDialog = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) => (
|
||||||
|
<AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogTrigger = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) => (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogPortal = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) => (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogOverlay = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot='alert-dialog-overlay'
|
||||||
|
className={cn(
|
||||||
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogContent = ({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
|
size?: 'default' | 'sm';
|
||||||
|
}) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot='alert-dialog-content'
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='alert-dialog-header'
|
||||||
|
className={cn(
|
||||||
|
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='alert-dialog-footer'
|
||||||
|
className={cn(
|
||||||
|
'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogMedia = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='alert-dialog-media'
|
||||||
|
className={cn(
|
||||||
|
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogTitle = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot='alert-dialog-title'
|
||||||
|
className={cn(
|
||||||
|
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogDescription = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot='alert-dialog-description'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogAction = ({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) => (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
data-slot='alert-dialog-action'
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDialogCancel = ({
|
||||||
|
className,
|
||||||
|
variant = 'outline',
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) => (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
data-slot='alert-dialog-cancel'
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
};
|
||||||
69
packages/ui/src/alert.tsx
Normal file
69
packages/ui/src/alert.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-card text-card-foreground',
|
||||||
|
destructive:
|
||||||
|
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = ({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) => (
|
||||||
|
<div
|
||||||
|
data-slot='alert'
|
||||||
|
role='alert'
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertTitle = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='alert-title'
|
||||||
|
className={cn(
|
||||||
|
'[&_a]:hover:text-foreground font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertDescription = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='alert-description'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground [&_a]:hover:text-foreground text-sm text-balance md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlertAction = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='alert-action'
|
||||||
|
className={cn('absolute top-2 right-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction };
|
||||||
10
packages/ui/src/aspect-ratio.tsx
Normal file
10
packages/ui/src/aspect-ratio.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AspectRatio as AspectRatioPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
const AspectRatio = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) => (
|
||||||
|
<AspectRatioPrimitive.Root data-slot='aspect-ratio' {...props} />
|
||||||
|
);
|
||||||
|
export { AspectRatio };
|
||||||
@@ -1,53 +1,97 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
import { Avatar as AvatarPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import { cn } from '.';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Avatar({
|
const Avatar = ({
|
||||||
className,
|
className,
|
||||||
|
size = 'default',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
return (
|
size?: 'default' | 'sm' | 'lg';
|
||||||
|
}) => (
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
data-slot='avatar'
|
data-slot='avatar'
|
||||||
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
'group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function AvatarImage({
|
const AvatarImage = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) => (
|
||||||
return (
|
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
data-slot='avatar-image'
|
data-slot='avatar-image'
|
||||||
className={cn('aspect-square size-full', className)}
|
className={cn('aspect-square size-full', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function AvatarFallback({
|
const AvatarFallback = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) => (
|
||||||
return (
|
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
data-slot='avatar-fallback'
|
data-slot='avatar-fallback'
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback };
|
const AvatarBadge = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
data-slot='avatar-badge'
|
||||||
|
className={cn(
|
||||||
|
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none',
|
||||||
|
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||||
|
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||||
|
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AvatarGroup = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='avatar-group'
|
||||||
|
className={cn(
|
||||||
|
'group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AvatarGroupCount = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='avatar-group-count'
|
||||||
|
className={cn(
|
||||||
|
'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
};
|
||||||
|
|||||||
49
packages/ui/src/badge.tsx
Normal file
49
packages/ui/src/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90 text-white',
|
||||||
|
outline:
|
||||||
|
'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||||
|
ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 [a&]:hover:underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Badge = ({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) => {
|
||||||
|
const Comp = asChild ? Slot.Root : 'span';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot='badge'
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
108
packages/ui/src/breadcrumb.tsx
Normal file
108
packages/ui/src/breadcrumb.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type * as React from 'react';
|
||||||
|
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const Breadcrumb = ({ ...props }: React.ComponentProps<'nav'>) => (
|
||||||
|
<nav aria-label='breadcrumb' data-slot='breadcrumb' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const BreadcrumbList = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'ol'>) => (
|
||||||
|
<ol
|
||||||
|
data-slot='breadcrumb-list'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BreadcrumbItem = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'li'>) => (
|
||||||
|
<li
|
||||||
|
data-slot='breadcrumb-item'
|
||||||
|
className={cn('inline-flex items-center gap-1.5', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BreadcrumbLink = ({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'a'> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) => {
|
||||||
|
const Comp = asChild ? Slot.Root : 'a';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot='breadcrumb-link'
|
||||||
|
className={cn('hover:text-foreground transition-colors', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BreadcrumbPage = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
data-slot='breadcrumb-page'
|
||||||
|
role='link'
|
||||||
|
aria-disabled='true'
|
||||||
|
aria-current='page'
|
||||||
|
className={cn('text-foreground font-normal', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'li'>) => (
|
||||||
|
<li
|
||||||
|
data-slot='breadcrumb-separator'
|
||||||
|
role='presentation'
|
||||||
|
aria-hidden='true'
|
||||||
|
className={cn('[&>svg]:size-3.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
data-slot='breadcrumb-ellipsis'
|
||||||
|
role='presentation'
|
||||||
|
aria-hidden='true'
|
||||||
|
className={cn('flex size-9 items-center justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className='size-4' />
|
||||||
|
<span className='sr-only'>More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
79
packages/ui/src/button-group.tsx
Normal file
79
packages/ui/src/button-group.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn, Separator } from '@gib/ui';
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!',
|
||||||
|
vertical:
|
||||||
|
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: 'horizontal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const ButtonGroup = ({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) => (
|
||||||
|
<div
|
||||||
|
role='group'
|
||||||
|
data-slot='button-group'
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ButtonGroupText = ({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) => {
|
||||||
|
const Comp = asChild ? Slot.Root : 'div';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex items-center gap-2 rounded-lg border px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ButtonGroupSeparator = ({
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) => (
|
||||||
|
<Separator
|
||||||
|
data-slot='button-group-separator'
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-input relative self-stretch data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
};
|
||||||
@@ -1,32 +1,38 @@
|
|||||||
import type { VariantProps } from 'class-variance-authority';
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
import * as React from 'react';
|
import type * as React from 'react';
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
|
||||||
import { cva } from 'class-variance-authority';
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
|
|
||||||
destructive:
|
|
||||||
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs',
|
|
||||||
outline:
|
outline:
|
||||||
'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
|
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs',
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
ghost:
|
ghost:
|
||||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
default:
|
||||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
icon: 'size-9',
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
|
||||||
|
icon: 'size-8',
|
||||||
|
'icon-xs':
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
'icon-sm':
|
||||||
|
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||||
|
'icon-lg': 'size-9',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -36,25 +42,27 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function Button({
|
const Button = ({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant = 'default',
|
||||||
size,
|
size = 'default',
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> &
|
}: React.ComponentProps<'button'> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}) {
|
}) => {
|
||||||
const Comp = asChild ? Slot : 'button';
|
const Comp = asChild ? Slot.Root : 'button';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot='button'
|
data-slot='button'
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants };
|
||||||
|
|||||||
227
packages/ui/src/calendar.tsx
Normal file
227
packages/ui/src/calendar.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { DayButton, Locale } from 'react-day-picker';
|
||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { DayPicker, getDefaultClassNames } from 'react-day-picker';
|
||||||
|
|
||||||
|
import { Button, buttonVariants, cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const Calendar = ({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = 'label',
|
||||||
|
buttonVariant = 'ghost',
|
||||||
|
locale,
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||||
|
}) => {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
'group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
locale={locale}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString(locale?.code, { month: 'short' }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn('w-fit', defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
'relative flex flex-col gap-4 md:flex-row',
|
||||||
|
defaultClassNames.months,
|
||||||
|
),
|
||||||
|
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
|
||||||
|
defaultClassNames.nav,
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
|
defaultClassNames.button_previous,
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
|
defaultClassNames.button_next,
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||||
|
defaultClassNames.month_caption,
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||||
|
defaultClassNames.dropdowns,
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
'cn-calendar-dropdown-root relative rounded-(--cell-radius)',
|
||||||
|
defaultClassNames.dropdown_root,
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
'bg-popover absolute inset-0 opacity-0',
|
||||||
|
defaultClassNames.dropdown,
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
'font-medium select-none',
|
||||||
|
captionLayout === 'label'
|
||||||
|
? 'text-sm'
|
||||||
|
: 'cn-calendar-caption-label [&>svg]:text-muted-foreground flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5',
|
||||||
|
defaultClassNames.caption_label,
|
||||||
|
),
|
||||||
|
table: 'w-full border-collapse',
|
||||||
|
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
'text-muted-foreground flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal select-none',
|
||||||
|
defaultClassNames.weekday,
|
||||||
|
),
|
||||||
|
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
'w-(--cell-size) select-none',
|
||||||
|
defaultClassNames.week_number_header,
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
'text-muted-foreground text-[0.8rem] select-none',
|
||||||
|
defaultClassNames.week_number,
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
|
||||||
|
props.showWeekNumber
|
||||||
|
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
|
||||||
|
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
|
||||||
|
defaultClassNames.day,
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
'bg-muted after:bg-muted relative isolate z-0 rounded-l-(--cell-radius) after:absolute after:inset-y-0 after:right-0 after:w-4',
|
||||||
|
defaultClassNames.range_start,
|
||||||
|
),
|
||||||
|
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||||
|
range_end: cn(
|
||||||
|
'bg-muted after:bg-muted relative isolate z-0 rounded-r-(--cell-radius) after:absolute after:inset-y-0 after:left-0 after:w-4',
|
||||||
|
defaultClassNames.range_end,
|
||||||
|
),
|
||||||
|
today: cn(
|
||||||
|
'bg-muted text-foreground rounded-(--cell-radius) data-[selected=true]:rounded-none',
|
||||||
|
defaultClassNames.today,
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||||
|
defaultClassNames.outside,
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
'text-muted-foreground opacity-50',
|
||||||
|
defaultClassNames.disabled,
|
||||||
|
),
|
||||||
|
hidden: cn('invisible', defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='calendar'
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === 'left') {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon
|
||||||
|
className={cn('cn-rtl-flip size-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === 'right') {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn('cn-rtl-flip size-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn('size-4', className)} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
DayButton: ({ ...props }) => (
|
||||||
|
<CalendarDayButton locale={locale} {...props} />
|
||||||
|
),
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className='flex size-(--cell-size) items-center justify-center text-center'>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CalendarDayButton = ({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
locale,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) => {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
'group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70',
|
||||||
|
defaultClassNames.day,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton };
|
||||||
@@ -1,55 +1,57 @@
|
|||||||
import * as React from 'react';
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
const Card = ({
|
||||||
return (
|
className,
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) => (
|
||||||
<div
|
<div
|
||||||
data-slot='card'
|
data-slot='card'
|
||||||
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
'ring-foreground/10 bg-card text-card-foreground group/card flex flex-col gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
const CardHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='card-header'
|
data-slot='card-header'
|
||||||
className={cn(
|
className={cn(
|
||||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
const CardTitle = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='card-title'
|
data-slot='card-title'
|
||||||
className={cn('leading-none font-semibold', className)}
|
className={cn(
|
||||||
|
'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
const CardDescription = ({
|
||||||
return (
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => (
|
||||||
<div
|
<div
|
||||||
data-slot='card-description'
|
data-slot='card-description'
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
const CardAction = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='card-action'
|
data-slot='card-action'
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -59,27 +61,25 @@ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
const CardContent = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='card-content'
|
data-slot='card-content'
|
||||||
className={cn('px-6', className)}
|
className={cn('px-4 group-data-[size=sm]/card:px-3', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
const CardFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='card-footer'
|
data-slot='card-footer'
|
||||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
className={cn(
|
||||||
|
'bg-muted/50 flex items-center rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
|
|||||||
243
packages/ui/src/carousel.tsx
Normal file
243
packages/ui/src/carousel.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { UseEmblaCarouselType } from 'embla-carousel-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import useEmblaCarousel from 'embla-carousel-react';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button, cn } from '@gib/ui';
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
interface CarouselProps {
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
setApi?: (api: CarouselApi) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
|
scrollPrev: () => void;
|
||||||
|
scrollNext: () => void;
|
||||||
|
canScrollPrev: boolean;
|
||||||
|
canScrollNext: boolean;
|
||||||
|
} & CarouselProps;
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
|
const useCarousel = () => {
|
||||||
|
const context = React.useContext(CarouselContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCarousel must be used within a <Carousel />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Carousel = ({
|
||||||
|
orientation = 'horizontal',
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & CarouselProps) => {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
);
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return;
|
||||||
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
|
setCanScrollNext(api.canScrollNext());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return;
|
||||||
|
setApi(api);
|
||||||
|
}, [api, setApi]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
onSelect(api);
|
||||||
|
api.on('reInit', onSelect);
|
||||||
|
api.on('select', onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off('select', onSelect);
|
||||||
|
};
|
||||||
|
}, [api, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn('relative', className)}
|
||||||
|
role='region'
|
||||||
|
aria-roledescription='carousel'
|
||||||
|
data-slot='carousel'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarouselContent = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => {
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className='overflow-hidden'
|
||||||
|
data-slot='carousel-content'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex',
|
||||||
|
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarouselItem = ({ className, ...props }: React.ComponentProps<'div'>) => {
|
||||||
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role='group'
|
||||||
|
aria-roledescription='slide'
|
||||||
|
data-slot='carousel-item'
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 shrink-0 grow-0 basis-full',
|
||||||
|
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarouselPrevious = ({
|
||||||
|
className,
|
||||||
|
variant = 'outline',
|
||||||
|
size = 'icon-sm',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) => {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot='carousel-previous'
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
'absolute touch-manipulation rounded-full',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? 'top-1/2 -left-12 -translate-y-1/2'
|
||||||
|
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className='cn-rtl-flip' />
|
||||||
|
<span className='sr-only'>Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarouselNext = ({
|
||||||
|
className,
|
||||||
|
variant = 'outline',
|
||||||
|
size = 'icon-sm',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) => {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot='carousel-next'
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
'absolute touch-manipulation rounded-full',
|
||||||
|
orientation === 'horizontal'
|
||||||
|
? 'top-1/2 -right-12 -translate-y-1/2'
|
||||||
|
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className='cn-rtl-flip' />
|
||||||
|
<span className='sr-only'>Next slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
useCarousel,
|
||||||
|
};
|
||||||
355
packages/ui/src/chart.tsx
Normal file
355
packages/ui/src/chart.tsx
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as RechartsPrimitive from 'recharts';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: '', dark: '.dark' } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface ChartContextProps {
|
||||||
|
config: ChartConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
const useChart = () => {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useChart must be used within a <ChartContainer />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContainer = ({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>['children'];
|
||||||
|
}) => {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot='chart'
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
const ChartTooltipContent = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = 'dot',
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: 'line' | 'dot' | 'dashed';
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === 'string'
|
||||||
|
? config[label]?.label || label
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn('font-medium', labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className='grid gap-1.5'>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== 'none')
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||||
|
indicator === 'dot' && 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
||||||
|
{
|
||||||
|
'h-2.5 w-2.5': indicator === 'dot',
|
||||||
|
'w-1': indicator === 'line',
|
||||||
|
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||||
|
indicator === 'dashed',
|
||||||
|
'my-0.5': nestLabel && indicator === 'dashed',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--color-bg': indicatorColor,
|
||||||
|
'--color-border': indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 justify-between leading-none',
|
||||||
|
nestLabel ? 'items-end' : 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className='grid gap-1.5'>
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className='text-muted-foreground'>
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className='text-foreground font-mono font-medium tabular-nums'>
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
const ChartLegendContent = ({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = 'bottom',
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<'div'> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-4',
|
||||||
|
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== 'none')
|
||||||
|
.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className='h-2 w-2 shrink-0 rounded-[2px]'
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPayloadConfigFromPayload = (
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string,
|
||||||
|
) => {
|
||||||
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
'payload' in payload &&
|
||||||
|
typeof payload.payload === 'object' &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === 'string'
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import type * as React from 'react';
|
||||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
|
||||||
import { CheckIcon } from 'lucide-react';
|
import { CheckIcon } from 'lucide-react';
|
||||||
|
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Checkbox({
|
const Checkbox = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) => (
|
||||||
return (
|
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot='checkbox'
|
data-slot='checkbox'
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
data-slot='checkbox-indicator'
|
data-slot='checkbox-indicator'
|
||||||
className='flex items-center justify-center text-current transition-none'
|
className='grid place-content-center text-current transition-none [&>svg]:size-3.5'
|
||||||
>
|
>
|
||||||
<CheckIcon className='size-3.5' />
|
<CheckIcon />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export { Checkbox };
|
export { Checkbox };
|
||||||
|
|||||||
29
packages/ui/src/collapsible.tsx
Normal file
29
packages/ui/src/collapsible.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
const Collapsible = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) => (
|
||||||
|
<CollapsiblePrimitive.Root data-slot='collapsible' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const CollapsibleTrigger = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) => (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot='collapsible-trigger'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CollapsibleContent = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) => (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot='collapsible-content'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
288
packages/ui/src/combobox.tsx
Normal file
288
packages/ui/src/combobox.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
|
||||||
|
import { CheckIcon, ChevronDownIcon, XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
cn,
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@gib/ui';
|
||||||
|
|
||||||
|
const Combobox = ComboboxPrimitive.Root;
|
||||||
|
|
||||||
|
const ComboboxValue = ({ ...props }: ComboboxPrimitive.Value.Props) => (
|
||||||
|
<ComboboxPrimitive.Value data-slot='combobox-value' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxTrigger = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Trigger.Props) => (
|
||||||
|
<ComboboxPrimitive.Trigger
|
||||||
|
data-slot='combobox-trigger'
|
||||||
|
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className='text-muted-foreground pointer-events-none size-4' />
|
||||||
|
</ComboboxPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxClear = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Clear.Props) => (
|
||||||
|
<ComboboxPrimitive.Clear
|
||||||
|
data-slot='combobox-clear'
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
render={
|
||||||
|
<InputGroupButton variant='ghost' size='icon-xs'>
|
||||||
|
<XIcon className='pointer-events-none' />
|
||||||
|
</InputGroupButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxInput = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
showTrigger = true,
|
||||||
|
showClear = false,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Input.Props & {
|
||||||
|
showTrigger?: boolean;
|
||||||
|
showClear?: boolean;
|
||||||
|
}) => (
|
||||||
|
<InputGroup className={cn('w-auto', className)}>
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
render={<InputGroupInput disabled={disabled} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align='inline-end'>
|
||||||
|
{showTrigger && (
|
||||||
|
<InputGroupButton
|
||||||
|
size='icon-xs'
|
||||||
|
variant='ghost'
|
||||||
|
render={<ComboboxTrigger />}
|
||||||
|
data-slot='input-group-button'
|
||||||
|
className='group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent'
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showClear && <ComboboxClear disabled={disabled} />}
|
||||||
|
</InputGroupAddon>
|
||||||
|
{children}
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxContent = ({
|
||||||
|
className,
|
||||||
|
side = 'bottom',
|
||||||
|
sideOffset = 6,
|
||||||
|
align = 'start',
|
||||||
|
alignOffset = 0,
|
||||||
|
anchor,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
ComboboxPrimitive.Positioner.Props,
|
||||||
|
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
|
||||||
|
>) => (
|
||||||
|
<ComboboxPrimitive.Portal>
|
||||||
|
<ComboboxPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
anchor={anchor}
|
||||||
|
className='isolate z-50'
|
||||||
|
>
|
||||||
|
<ComboboxPrimitive.Popup
|
||||||
|
data-slot='combobox-content'
|
||||||
|
data-chips={!!anchor}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ComboboxPrimitive.Positioner>
|
||||||
|
</ComboboxPrimitive.Portal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxList = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.List.Props) => (
|
||||||
|
<ComboboxPrimitive.List
|
||||||
|
data-slot='combobox-list'
|
||||||
|
className={cn(
|
||||||
|
'no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxItem = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Item.Props) => (
|
||||||
|
<ComboboxPrimitive.Item
|
||||||
|
data-slot='combobox-item'
|
||||||
|
className={cn(
|
||||||
|
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ComboboxPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className='pointer-events-none absolute right-2 flex size-4 items-center justify-center'>
|
||||||
|
<CheckIcon className='pointer-events-none' />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ComboboxPrimitive.Item>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Group.Props) => (
|
||||||
|
<ComboboxPrimitive.Group
|
||||||
|
data-slot='combobox-group'
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxLabel = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.GroupLabel.Props) => (
|
||||||
|
<ComboboxPrimitive.GroupLabel
|
||||||
|
data-slot='combobox-label'
|
||||||
|
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxCollection = ({
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Collection.Props) => (
|
||||||
|
<ComboboxPrimitive.Collection data-slot='combobox-collection' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxEmpty = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Empty.Props) => (
|
||||||
|
<ComboboxPrimitive.Empty
|
||||||
|
data-slot='combobox-empty'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxSeparator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Separator.Props) => (
|
||||||
|
<ComboboxPrimitive.Separator
|
||||||
|
data-slot='combobox-separator'
|
||||||
|
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxChips = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||||
|
ComboboxPrimitive.Chips.Props) => (
|
||||||
|
<ComboboxPrimitive.Chips
|
||||||
|
data-slot='combobox-chips'
|
||||||
|
className={cn(
|
||||||
|
'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-3 has-aria-invalid:ring-3 has-data-[slot=combobox-chip]:px-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxChip = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showRemove = true,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Chip.Props & {
|
||||||
|
showRemove?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ComboboxPrimitive.Chip
|
||||||
|
data-slot='combobox-chip'
|
||||||
|
className={cn(
|
||||||
|
'bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showRemove && (
|
||||||
|
<ComboboxPrimitive.ChipRemove
|
||||||
|
className='-ml-1 opacity-50 hover:opacity-100'
|
||||||
|
data-slot='combobox-chip-remove'
|
||||||
|
render={
|
||||||
|
<Button variant='ghost' size='icon-xs'>
|
||||||
|
<XIcon className='pointer-events-none' />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ComboboxPrimitive.Chip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ComboboxChipsInput = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Input.Props) => (
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
data-slot='combobox-chip-input'
|
||||||
|
className={cn('min-w-16 flex-1 outline-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const useComboboxAnchor = () => React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxGroup,
|
||||||
|
ComboboxLabel,
|
||||||
|
ComboboxCollection,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxSeparator,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxValue,
|
||||||
|
useComboboxAnchor,
|
||||||
|
};
|
||||||
175
packages/ui/src/command.tsx
Normal file
175
packages/ui/src/command.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
|
import { CheckIcon, SearchIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
cn,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from '@gib/ui';
|
||||||
|
|
||||||
|
const Command = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot='command'
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-xl! p-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommandDialog = ({
|
||||||
|
title = 'Command Palette',
|
||||||
|
description = 'Search for a command to run...',
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
className?: string;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) => (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className='sr-only'>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
'top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommandInput = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) => (
|
||||||
|
<div data-slot='command-input-wrapper' className='p-1 pb-0'>
|
||||||
|
<InputGroup className='bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!'>
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot='command-input'
|
||||||
|
className={cn(
|
||||||
|
'w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className='size-4 shrink-0 opacity-50' />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommandList = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot='command-list'
|
||||||
|
className={cn(
|
||||||
|
'no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommandEmpty = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot='command-empty'
|
||||||
|
className={cn('py-6 text-center text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommandGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot='command-group'
|
||||||
|
className={cn(
|
||||||
|
'text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommandSeparator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot='command-separator'
|
||||||
|
className={cn('bg-border -mx-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommandItem = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot='command-item'
|
||||||
|
className={cn(
|
||||||
|
"data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className='ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100' />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
data-slot='command-shortcut'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
241
packages/ui/src/context-menu.tsx
Normal file
241
packages/ui/src/context-menu.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
|
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const ContextMenu = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) => (
|
||||||
|
<ContextMenuPrimitive.Root data-slot='context-menu' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) => (
|
||||||
|
<ContextMenuPrimitive.Trigger
|
||||||
|
data-slot='context-menu-trigger'
|
||||||
|
className={cn('select-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuGroup = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) => (
|
||||||
|
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuPortal = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) => (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuSub = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) => (
|
||||||
|
<ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) => (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot='context-menu-radio-group'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuContent = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
}) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot='context-menu-content'
|
||||||
|
className={cn(
|
||||||
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuItem = ({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
}) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot='context-menu-item'
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = ({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot='context-menu-sub-trigger'
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className='cn-rtl-flip ml-auto' />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuSubContent = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot='context-menu-sub-content'
|
||||||
|
className={cn(
|
||||||
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border p-1 shadow-lg duration-100',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot='context-menu-checkbox-item'
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className='pointer-events-none absolute right-2'>
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot='context-menu-radio-item'
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className='pointer-events-none absolute right-2'>
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuLabel = ({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot='context-menu-label'
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuSeparator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot='context-menu-separator'
|
||||||
|
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
data-slot='context-menu-shortcut'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
||||||
151
packages/ui/src/dialog.tsx
Normal file
151
packages/ui/src/dialog.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { Button, cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const Dialog = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) => (
|
||||||
|
<DialogPrimitive.Root data-slot='dialog' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogTrigger = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) => (
|
||||||
|
<DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogPortal = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) => (
|
||||||
|
<DialogPrimitive.Portal data-slot='dialog-portal' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogClose = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) => (
|
||||||
|
<DialogPrimitive.Close data-slot='dialog-close' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogOverlay = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot='dialog-overlay'
|
||||||
|
className={cn(
|
||||||
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogContent = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot='dialog-content'
|
||||||
|
className={cn(
|
||||||
|
'bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot='dialog-close' asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
className='absolute top-2 right-2'
|
||||||
|
size='icon-sm'
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className='sr-only'>Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='dialog-header'
|
||||||
|
className={cn('flex flex-col gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-slot='dialog-footer'
|
||||||
|
className={cn(
|
||||||
|
'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 sm:flex-row sm:justify-end',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant='outline'>Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogTitle = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot='dialog-title'
|
||||||
|
className={cn('text-base leading-none font-medium', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogDescription = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot='dialog-description'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
@@ -1,125 +1,109 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import type * as React from 'react';
|
||||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Drawer({
|
const Drawer = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||||
return <DrawerPrimitive.Root data-slot='drawer' {...props} />;
|
<DrawerPrimitive.Root data-slot='drawer' {...props} />
|
||||||
}
|
);
|
||||||
|
|
||||||
function DrawerTrigger({
|
const DrawerTrigger = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) => (
|
||||||
return <DrawerPrimitive.Trigger data-slot='drawer-trigger' {...props} />;
|
<DrawerPrimitive.Trigger data-slot='drawer-trigger' {...props} />
|
||||||
}
|
);
|
||||||
|
|
||||||
function DrawerPortal({
|
const DrawerPortal = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) => (
|
||||||
return <DrawerPrimitive.Portal data-slot='drawer-portal' {...props} />;
|
<DrawerPrimitive.Portal data-slot='drawer-portal' {...props} />
|
||||||
}
|
);
|
||||||
|
|
||||||
function DrawerClose({
|
const DrawerClose = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) => (
|
||||||
return <DrawerPrimitive.Close data-slot='drawer-close' {...props} />;
|
<DrawerPrimitive.Close data-slot='drawer-close' {...props} />
|
||||||
}
|
);
|
||||||
|
|
||||||
function DrawerOverlay({
|
const DrawerOverlay = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) => (
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Overlay
|
<DrawerPrimitive.Overlay
|
||||||
data-slot='drawer-overlay'
|
data-slot='drawer-overlay'
|
||||||
className={cn(
|
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',
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerContent({
|
const DrawerContent = ({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) => (
|
||||||
return (
|
|
||||||
<DrawerPortal data-slot='drawer-portal'>
|
<DrawerPortal data-slot='drawer-portal'>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
data-slot='drawer-content'
|
data-slot='drawer-content'
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
'bg-background group/drawer-content fixed z-50 flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||||
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
|
||||||
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
|
||||||
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
|
||||||
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className='bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block' />
|
<div className='bg-muted mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block' />
|
||||||
{children}
|
{children}
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPortal>
|
</DrawerPortal>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
const DrawerHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='drawer-header'
|
data-slot='drawer-header'
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
|
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
const DrawerFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='drawer-footer'
|
data-slot='drawer-footer'
|
||||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerTitle({
|
const DrawerTitle = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) => (
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Title
|
<DrawerPrimitive.Title
|
||||||
data-slot='drawer-title'
|
data-slot='drawer-title'
|
||||||
className={cn('text-foreground font-semibold', className)}
|
className={cn('text-foreground text-base font-medium', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerDescription({
|
const DrawerDescription = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) => (
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Description
|
<DrawerPrimitive.Description
|
||||||
data-slot='drawer-description'
|
data-slot='drawer-description'
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Drawer,
|
Drawer,
|
||||||
|
|||||||
@@ -1,65 +1,56 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import type * as React from 'react';
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function DropdownMenu({
|
const DropdownMenu = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) => (
|
||||||
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
|
<DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />
|
||||||
}
|
);
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
const DropdownMenuPortal = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
|
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
const DropdownMenuTrigger = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) => (
|
||||||
return (
|
<DropdownMenuPrimitive.Trigger data-slot='dropdown-menu-trigger' {...props} />
|
||||||
<DropdownMenuPrimitive.Trigger
|
|
||||||
data-slot='dropdown-menu-trigger'
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuContent({
|
const DropdownMenuContent = ({
|
||||||
className,
|
className,
|
||||||
|
align = 'start',
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
data-slot='dropdown-menu-content'
|
data-slot='dropdown-menu-content'
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 data-[state=closed]:overflow-hidden',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
const DropdownMenuGroup = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
|
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuItem({
|
const DropdownMenuItem = ({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
@@ -67,176 +58,172 @@ function DropdownMenuItem({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
variant?: 'default' | 'destructive';
|
variant?: 'default' | 'destructive';
|
||||||
}) {
|
}) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
data-slot='dropdown-menu-item'
|
data-slot='dropdown-menu-item'
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
const DropdownMenuCheckboxItem = ({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
checked,
|
checked,
|
||||||
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
return (
|
inset?: boolean;
|
||||||
|
}) => (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot='dropdown-menu-checkbox-item'
|
data-slot='dropdown-menu-checkbox-item'
|
||||||
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
<span
|
||||||
|
className='pointer-events-none absolute right-2 flex items-center justify-center'
|
||||||
|
data-slot='dropdown-menu-checkbox-item-indicator'
|
||||||
|
>
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CheckIcon className='size-4' />
|
<CheckIcon />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
const DropdownMenuRadioGroup = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
data-slot='dropdown-menu-radio-group'
|
data-slot='dropdown-menu-radio-group'
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
const DropdownMenuRadioItem = ({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
return (
|
inset?: boolean;
|
||||||
|
}) => (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot='dropdown-menu-radio-item'
|
data-slot='dropdown-menu-radio-item'
|
||||||
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
<span
|
||||||
|
className='pointer-events-none absolute right-2 flex items-center justify-center'
|
||||||
|
data-slot='dropdown-menu-radio-item-indicator'
|
||||||
|
>
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CircleIcon className='size-2 fill-current' />
|
<CheckIcon />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
const DropdownMenuLabel = ({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}) {
|
}) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot='dropdown-menu-label'
|
data-slot='dropdown-menu-label'
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
const DropdownMenuSeparator = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot='dropdown-menu-separator'
|
data-slot='dropdown-menu-separator'
|
||||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
const DropdownMenuShortcut = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'>) {
|
}: React.ComponentProps<'span'>) => (
|
||||||
return (
|
|
||||||
<span
|
<span
|
||||||
data-slot='dropdown-menu-shortcut'
|
data-slot='dropdown-menu-shortcut'
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSub({
|
const DropdownMenuSub = ({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) => (
|
||||||
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
|
<DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />
|
||||||
}
|
);
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
const DropdownMenuSubTrigger = ({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}) {
|
}) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
data-slot='dropdown-menu-sub-trigger'
|
data-slot='dropdown-menu-sub-trigger'
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className='ml-auto size-4' />
|
<ChevronRightIcon className='cn-rtl-flip ml-auto' />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
const DropdownMenuSubContent = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) => (
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot='dropdown-menu-sub-content'
|
data-slot='dropdown-menu-sub-content'
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg p-1 shadow-lg ring-1 duration-100',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|||||||
93
packages/ui/src/empty.tsx
Normal file
93
packages/ui/src/empty.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const Empty = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='empty'
|
||||||
|
className={cn(
|
||||||
|
'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='empty-header'
|
||||||
|
className={cn('flex max-w-sm flex-col items-center gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyMedia = ({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) => (
|
||||||
|
<div
|
||||||
|
data-slot='empty-icon'
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyTitle = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='empty-title'
|
||||||
|
className={cn('text-sm font-medium tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyDescription = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'p'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='empty-description'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyContent = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='empty-content'
|
||||||
|
className={cn(
|
||||||
|
'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyMedia,
|
||||||
|
};
|
||||||
@@ -6,11 +6,10 @@ import { cva } from 'class-variance-authority';
|
|||||||
|
|
||||||
import { cn, Label, Separator } from '@gib/ui';
|
import { cn, Label, Separator } from '@gib/ui';
|
||||||
|
|
||||||
export function FieldSet({
|
export const FieldSet = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'fieldset'>) {
|
}: React.ComponentProps<'fieldset'>) => (
|
||||||
return (
|
|
||||||
<fieldset
|
<fieldset
|
||||||
data-slot='field-set'
|
data-slot='field-set'
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -21,14 +20,12 @@ export function FieldSet({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldLegend({
|
export const FieldLegend = ({
|
||||||
className,
|
className,
|
||||||
variant = 'legend',
|
variant = 'legend',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) => (
|
||||||
return (
|
|
||||||
<legend
|
<legend
|
||||||
data-slot='field-legend'
|
data-slot='field-legend'
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
@@ -41,13 +38,11 @@ export function FieldLegend({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldGroup({
|
export const FieldGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='field-group'
|
data-slot='field-group'
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -57,7 +52,6 @@ export function FieldGroup({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const fieldVariants = cva(
|
const fieldVariants = cva(
|
||||||
'group/field data-[invalid=true]:text-destructive flex w-full gap-3',
|
'group/field data-[invalid=true]:text-destructive flex w-full gap-3',
|
||||||
@@ -83,12 +77,11 @@ const fieldVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export function Field({
|
export const Field = ({
|
||||||
className,
|
className,
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
role='group'
|
role='group'
|
||||||
data-slot='field'
|
data-slot='field'
|
||||||
@@ -97,13 +90,11 @@ export function Field({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldContent({
|
export const FieldContent = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='field-content'
|
data-slot='field-content'
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -113,13 +104,11 @@ export function FieldContent({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldLabel({
|
export const FieldLabel = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Label>) {
|
}: React.ComponentProps<typeof Label>) => (
|
||||||
return (
|
|
||||||
<Label
|
<Label
|
||||||
data-slot='field-label'
|
data-slot='field-label'
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -131,13 +120,11 @@ export function FieldLabel({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldTitle({
|
export const FieldTitle = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<'div'>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='field-label'
|
data-slot='field-label'
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -147,13 +134,11 @@ export function FieldTitle({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldDescription({
|
export const FieldDescription = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'p'>) {
|
}: React.ComponentProps<'p'>) => (
|
||||||
return (
|
|
||||||
<p
|
<p
|
||||||
data-slot='field-description'
|
data-slot='field-description'
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -165,16 +150,14 @@ export function FieldDescription({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldSeparator({
|
export const FieldSeparator = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<'div'> & {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot='field-separator'
|
data-slot='field-separator'
|
||||||
data-content={!!children}
|
data-content={!!children}
|
||||||
@@ -195,16 +178,15 @@ export function FieldSeparator({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldError({
|
export const FieldError = ({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
errors: maybeErrors,
|
errors: maybeErrors,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<'div'> & {
|
||||||
errors?: ({ message?: string } | undefined)[];
|
errors?: ({ message?: string } | undefined)[];
|
||||||
}) {
|
}) => {
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (children) {
|
if (children) {
|
||||||
return children;
|
return children;
|
||||||
@@ -244,4 +226,4 @@ export function FieldError({
|
|||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import { cn, Label } from '@gib/ui';
|
|||||||
|
|
||||||
const Form = FormProvider;
|
const Form = FormProvider;
|
||||||
|
|
||||||
type FormFieldContextValue<
|
interface FormFieldContextValue<
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
> = {
|
> {
|
||||||
name: TName;
|
name: TName;
|
||||||
};
|
}
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
{} as FormFieldContextValue,
|
{} as FormFieldContextValue,
|
||||||
@@ -62,15 +62,15 @@ const useFormField = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormItemContextValue = {
|
interface FormItemContextValue {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
{} as FormItemContextValue,
|
{} as FormItemContextValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
const FormItem = ({ className, ...props }: React.ComponentProps<'div'>) => {
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,12 +82,12 @@ function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
/>
|
/>
|
||||||
</FormItemContext.Provider>
|
</FormItemContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function FormLabel({
|
const FormLabel = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) => {
|
||||||
const { error, formItemId } = useFormField();
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -99,9 +99,9 @@ function FormLabel({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
const FormControl = ({ ...props }: React.ComponentProps<typeof Slot>) => {
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
useFormField();
|
useFormField();
|
||||||
|
|
||||||
@@ -118,9 +118,12 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
const FormDescription = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'p'>) => {
|
||||||
const { formDescriptionId } = useFormField();
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,9 +134,9 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
const FormMessage = ({ className, ...props }: React.ComponentProps<'p'>) => {
|
||||||
const { error, formMessageId } = useFormField();
|
const { error, formMessageId } = useFormField();
|
||||||
const body = error ? String(error?.message ?? '') : props.children;
|
const body = error ? String(error?.message ?? '') : props.children;
|
||||||
|
|
||||||
@@ -151,7 +154,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
|||||||
{body}
|
{body}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
useFormField,
|
||||||
|
|||||||
2
packages/ui/src/hooks/index.tsx
Normal file
2
packages/ui/src/hooks/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { useIsMobile } from './use-mobile';
|
||||||
|
export { useOnClickOutside } from './use-on-click-outside';
|
||||||
21
packages/ui/src/hooks/use-mobile.ts
Normal file
21
packages/ui/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export const useIsMobile = () => {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
};
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !!isMobile;
|
||||||
|
};
|
||||||
60
packages/ui/src/hooks/use-on-click-outside.tsx
Normal file
60
packages/ui/src/hooks/use-on-click-outside.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { MousePointerClick, X } from 'lucide-react';
|
||||||
|
|
||||||
|
type EventType =
|
||||||
|
| 'mousedown'
|
||||||
|
| 'mouseup'
|
||||||
|
| 'touchstart'
|
||||||
|
| 'touchend'
|
||||||
|
| 'focusin'
|
||||||
|
| 'focusout';
|
||||||
|
|
||||||
|
export const useOnClickOutside = <T extends Element>(
|
||||||
|
ref: React.RefObject<T | null> | React.RefObject<T | null>[],
|
||||||
|
handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
|
||||||
|
eventType: EventType = 'mousedown',
|
||||||
|
eventListenerOptions: AddEventListenerOptions = {},
|
||||||
|
): void => {
|
||||||
|
const savedHandler = React.useRef(handler);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
savedHandler.current = handler;
|
||||||
|
}, [handler]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const listener = (event: MouseEvent | TouchEvent | FocusEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
|
||||||
|
// Do nothing if the target is not connected element with document
|
||||||
|
if (!target.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOutside = Array.isArray(ref)
|
||||||
|
? ref
|
||||||
|
.filter((r) => Boolean(r.current))
|
||||||
|
.every((r) => r.current && !r.current.contains(target))
|
||||||
|
: ref.current && !ref.current.contains(target);
|
||||||
|
|
||||||
|
if (isOutside) {
|
||||||
|
savedHandler.current(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
eventType,
|
||||||
|
listener as EventListener,
|
||||||
|
eventListenerOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(
|
||||||
|
eventType,
|
||||||
|
listener as EventListener,
|
||||||
|
eventListenerOptions,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [ref, eventType, eventListenerOptions]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { EventType };
|
||||||
40
packages/ui/src/hover-card.tsx
Normal file
40
packages/ui/src/hover-card.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { HoverCard as HoverCardPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const HoverCard = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) => (
|
||||||
|
<HoverCardPrimitive.Root data-slot='hover-card' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const HoverCardTrigger = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) => (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot='hover-card-trigger' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const HoverCardContent = ({
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) => (
|
||||||
|
<HoverCardPrimitive.Portal data-slot='hover-card-portal'>
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot='hover-card-content'
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||||
@@ -25,6 +25,9 @@ import { Button, cn } from '@gib/ui';
|
|||||||
|
|
||||||
import 'react-image-crop/dist/ReactCrop.css';
|
import 'react-image-crop/dist/ReactCrop.css';
|
||||||
|
|
||||||
|
// Demo
|
||||||
|
import { UploadIcon } from 'lucide-react';
|
||||||
|
|
||||||
const centerAspectCrop = (
|
const centerAspectCrop = (
|
||||||
mediaWidth: number,
|
mediaWidth: number,
|
||||||
mediaHeight: number,
|
mediaHeight: number,
|
||||||
@@ -150,7 +153,7 @@ export const ImageCrop = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.addEventListener('load', () =>
|
reader.addEventListener('load', () =>
|
||||||
setImgSrc(reader.result?.toString() ?? ''),
|
setImgSrc(reader.result?.toString() || ''),
|
||||||
);
|
);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}, [file]);
|
}, [file]);
|
||||||
@@ -170,7 +173,6 @@ export const ImageCrop = ({
|
|||||||
onChange?.(pixelCrop, percentCrop);
|
onChange?.(pixelCrop, percentCrop);
|
||||||
};
|
};
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/useAwait: "onComplete is async"
|
|
||||||
const handleComplete = async (
|
const handleComplete = async (
|
||||||
pixelCrop: PixelCrop,
|
pixelCrop: PixelCrop,
|
||||||
percentCrop: PercentCrop,
|
percentCrop: PercentCrop,
|
||||||
@@ -224,10 +226,10 @@ export const ImageCrop = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImageCropContentProps = {
|
export interface ImageCropContentProps {
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ImageCropContent = ({
|
export const ImageCropContent = ({
|
||||||
style,
|
style,
|
||||||
@@ -261,9 +263,11 @@ export const ImageCropContent = ({
|
|||||||
<img
|
<img
|
||||||
alt='crop'
|
alt='crop'
|
||||||
className='size-full'
|
className='size-full'
|
||||||
|
height={400}
|
||||||
onLoad={onImageLoad}
|
onLoad={onImageLoad}
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
src={imgSrc}
|
src={imgSrc}
|
||||||
|
width={400}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ReactCrop>
|
</ReactCrop>
|
||||||
@@ -289,14 +293,19 @@ export const ImageCropApply = ({
|
|||||||
|
|
||||||
if (asChild) {
|
if (asChild) {
|
||||||
return (
|
return (
|
||||||
<Slot.Root onClick={handleClick} {...props}>
|
<Slot.Root onClick={handleClick} {...(props as any)}>
|
||||||
{children}
|
{children}
|
||||||
</Slot.Root>
|
</Slot.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={handleClick} size='icon' variant='ghost' {...props}>
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
size='icon'
|
||||||
|
variant='ghost'
|
||||||
|
{...(props as any)}
|
||||||
|
>
|
||||||
{children ?? <CropIcon className='size-4' />}
|
{children ?? <CropIcon className='size-4' />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -321,14 +330,19 @@ export const ImageCropReset = ({
|
|||||||
|
|
||||||
if (asChild) {
|
if (asChild) {
|
||||||
return (
|
return (
|
||||||
<Slot.Root onClick={handleClick} {...props}>
|
<Slot.Root onClick={handleClick} {...(props as any)}>
|
||||||
{children}
|
{children}
|
||||||
</Slot.Root>
|
</Slot.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={handleClick} size='icon' variant='ghost' {...props}>
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
size='icon'
|
||||||
|
variant='ghost'
|
||||||
|
{...(props as any)}
|
||||||
|
>
|
||||||
{children ?? <RotateCcwIcon className='size-4' />}
|
{children ?? <RotateCcwIcon className='size-4' />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -358,8 +372,64 @@ export const Cropper = ({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onComplete={onComplete}
|
onComplete={onComplete}
|
||||||
onCrop={onCrop}
|
onCrop={onCrop}
|
||||||
{...props}
|
{...(props as any)}
|
||||||
>
|
>
|
||||||
<ImageCropContent className={className} style={style} />
|
<ImageCropContent className={className} style={style} />
|
||||||
</ImageCrop>
|
</ImageCrop>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const Demo = () => {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [croppedImage, setCroppedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = e.target.files?.[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
setFile(selectedFile);
|
||||||
|
setCroppedImage(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='fixed inset-0 flex items-center justify-center p-8'>
|
||||||
|
<div className='flex flex-col items-center gap-4'>
|
||||||
|
{!file ? (
|
||||||
|
<label className='border-muted-foreground/25 hover:border-muted-foreground/50 flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors'>
|
||||||
|
<UploadIcon className='text-muted-foreground size-8' />
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
Click to upload an image
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type='file'
|
||||||
|
accept='image/*'
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className='hidden'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<div className='flex flex-col items-center gap-4'>
|
||||||
|
<ImageCrop file={file} aspect={1} onCrop={setCroppedImage}>
|
||||||
|
<ImageCropContent className='max-w-sm' />
|
||||||
|
<div className='mt-2 flex justify-center gap-2'>
|
||||||
|
<ImageCropReset />
|
||||||
|
<ImageCropApply />
|
||||||
|
</div>
|
||||||
|
</ImageCrop>
|
||||||
|
{croppedImage && (
|
||||||
|
<div className='flex flex-col items-center gap-2'>
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
Cropped result:
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
src={croppedImage}
|
||||||
|
alt='Cropped'
|
||||||
|
className='max-w-32 rounded-lg'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,10 +15,56 @@ export const ccn = ({
|
|||||||
off: string;
|
off: string;
|
||||||
}) => twMerge(className, context ? on : off);
|
}) => twMerge(className, context ? on : off);
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback } from './avatar';
|
export {
|
||||||
|
Accordion,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
AccordionContent,
|
||||||
|
} from './accordion';
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction } from './alert';
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from './alert-dialog';
|
||||||
|
export { AspectRatio } from './aspect-ratio';
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
} from './avatar';
|
||||||
|
export { Badge, badgeVariants } from './badge';
|
||||||
export { BasedAvatar } from './based-avatar';
|
export { BasedAvatar } from './based-avatar';
|
||||||
export { BasedProgress } from './based-progress';
|
export { BasedProgress } from './based-progress';
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
} from './breadcrumb';
|
||||||
export { Button, buttonVariants } from './button';
|
export { Button, buttonVariants } from './button';
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
} from './button-group';
|
||||||
|
export { Calendar, CalendarDayButton } from './calendar';
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
@@ -28,7 +74,87 @@ export {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
} from './card';
|
} from './card';
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
useCarousel,
|
||||||
|
} from './carousel';
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
} from './chart';
|
||||||
export { Checkbox } from './checkbox';
|
export { Checkbox } from './checkbox';
|
||||||
|
export {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
CollapsibleContent,
|
||||||
|
} from './collapsible';
|
||||||
|
export {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxGroup,
|
||||||
|
ComboboxLabel,
|
||||||
|
ComboboxCollection,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxSeparator,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxValue,
|
||||||
|
useComboboxAnchor,
|
||||||
|
} from './combobox';
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
} from './command';
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
} from './context-menu';
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from './dialog';
|
||||||
export {
|
export {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerPortal,
|
DrawerPortal,
|
||||||
@@ -43,12 +169,41 @@ export {
|
|||||||
} from './drawer';
|
} from './drawer';
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuPortal,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
} from './dropdown-menu';
|
} from './dropdown-menu';
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyMedia,
|
||||||
|
} from './empty';
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
} from './field';
|
||||||
export {
|
export {
|
||||||
useFormField,
|
useFormField,
|
||||||
Form,
|
Form,
|
||||||
@@ -59,6 +214,7 @@ export {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
FormField,
|
FormField,
|
||||||
} from './form';
|
} from './form';
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent } from './hover-card';
|
||||||
export {
|
export {
|
||||||
type ImageCropProps,
|
type ImageCropProps,
|
||||||
type ImageCropApplyProps,
|
type ImageCropApplyProps,
|
||||||
@@ -70,27 +226,130 @@ export {
|
|||||||
ImageCropApply,
|
ImageCropApply,
|
||||||
ImageCropContent,
|
ImageCropContent,
|
||||||
ImageCropReset,
|
ImageCropReset,
|
||||||
} from './shadcn-io/image-crop';
|
} from './image-crop';
|
||||||
export { Input } from './input';
|
export { Input } from './input';
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
} from './input-group';
|
||||||
export {
|
export {
|
||||||
InputOTP,
|
InputOTP,
|
||||||
InputOTPGroup,
|
InputOTPGroup,
|
||||||
InputOTPSlot,
|
InputOTPSlot,
|
||||||
InputOTPSeparator,
|
InputOTPSeparator,
|
||||||
} from './input-otp';
|
} from './input-otp';
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
} from './item';
|
||||||
|
export { Kbd, KbdGroup } from './kbd';
|
||||||
export { Label } from './label';
|
export { Label } from './label';
|
||||||
|
export {
|
||||||
|
NativeSelect,
|
||||||
|
NativeSelectOptGroup,
|
||||||
|
NativeSelectOption,
|
||||||
|
} from './native-select';
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
} from './navigation-menu';
|
||||||
export {
|
export {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
PaginationLink,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationPrevious,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationEllipsis,
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
} from './pagination';
|
} from './pagination';
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverDescription,
|
||||||
|
} from './popover';
|
||||||
export { Progress } from './progress';
|
export { Progress } from './progress';
|
||||||
|
export { RadioGroup, RadioGroupItem } from './radio-group';
|
||||||
|
export {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from './resizable';
|
||||||
export { ScrollArea, ScrollBar } from './scroll-area';
|
export { ScrollArea, ScrollBar } from './scroll-area';
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from './select';
|
||||||
export { Separator } from './separator';
|
export { Separator } from './separator';
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
} from './sheet';
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
} from './sidebar';
|
||||||
|
export { Skeleton } from './skeleton';
|
||||||
|
export { Slider } from './slider';
|
||||||
|
export { Spinner } from './spinner';
|
||||||
export { StatusMessage } from './status-message';
|
export { StatusMessage } from './status-message';
|
||||||
export { SubmitButton } from './submit-button';
|
export { SubmitButton } from './submit-button';
|
||||||
export { Switch } from './switch';
|
export { Switch } from './switch';
|
||||||
@@ -104,6 +363,22 @@ export {
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
} from './table';
|
} from './table';
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs';
|
export {
|
||||||
export { Toaster } from './sonner';
|
Tabs,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
TabsContent,
|
||||||
|
tabsListVariants,
|
||||||
|
} from './tabs';
|
||||||
|
export { Textarea } from './textarea';
|
||||||
export { ThemeProvider, ThemeToggle, type ThemeToggleProps } from './theme';
|
export { ThemeProvider, ThemeToggle, type ThemeToggleProps } from './theme';
|
||||||
|
export { Toaster } from './sonner';
|
||||||
|
export { Toggle, toggleVariants } from './toggle';
|
||||||
|
export { ToggleGroup, ToggleGroupItem } from './toggle-group';
|
||||||
|
export {
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
} from './tooltip';
|
||||||
|
export { useIsMobile, useOnClickOutside } from './hooks';
|
||||||
|
|||||||
146
packages/ui/src/input-group.tsx
Normal file
146
packages/ui/src/input-group.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { Button, cn, Input, Textarea } from '@gib/ui';
|
||||||
|
|
||||||
|
const InputGroup = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='input-group'
|
||||||
|
role='group'
|
||||||
|
className={cn(
|
||||||
|
'border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
'inline-start':
|
||||||
|
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
|
||||||
|
'inline-end':
|
||||||
|
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
|
||||||
|
'block-start':
|
||||||
|
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
|
||||||
|
'block-end':
|
||||||
|
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: 'inline-start',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const InputGroupAddon = ({
|
||||||
|
className,
|
||||||
|
align = 'inline-start',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> &
|
||||||
|
VariantProps<typeof inputGroupAddonVariants>) => (
|
||||||
|
<div
|
||||||
|
role='group'
|
||||||
|
data-slot='input-group-addon'
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest('button')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector('input')?.focus();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
'flex items-center gap-2 text-sm shadow-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: '',
|
||||||
|
'icon-xs':
|
||||||
|
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
|
||||||
|
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'xs',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const InputGroupButton = ({
|
||||||
|
className,
|
||||||
|
type = 'button',
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'xs',
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) => (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InputGroupText = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InputGroupInput = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'input'>) => (
|
||||||
|
<Input
|
||||||
|
data-slot='input-group-control'
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InputGroupTextarea = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'textarea'>) => (
|
||||||
|
<Textarea
|
||||||
|
data-slot='input-group-control'
|
||||||
|
className={cn(
|
||||||
|
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
};
|
||||||
@@ -6,43 +6,46 @@ import { MinusIcon } from 'lucide-react';
|
|||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function InputOTP({
|
const InputOTP = ({
|
||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof OTPInput> & {
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
}) {
|
}) => (
|
||||||
return (
|
|
||||||
<OTPInput
|
<OTPInput
|
||||||
data-slot='input-otp'
|
data-slot='input-otp'
|
||||||
containerClassName={cn(
|
containerClassName={cn(
|
||||||
'flex items-center gap-2 has-disabled:opacity-50',
|
'cn-input-otp flex items-center has-disabled:opacity-50',
|
||||||
containerClassName,
|
containerClassName,
|
||||||
)}
|
)}
|
||||||
|
spellCheck={false}
|
||||||
className={cn('disabled:cursor-not-allowed', className)}
|
className={cn('disabled:cursor-not-allowed', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
const InputOTPGroup = ({
|
||||||
return (
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => (
|
||||||
<div
|
<div
|
||||||
data-slot='input-otp-group'
|
data-slot='input-otp-group'
|
||||||
className={cn('flex items-center', className)}
|
className={cn(
|
||||||
|
'has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive flex items-center rounded-lg has-aria-invalid:ring-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function InputOTPSlot({
|
const InputOTPSlot = ({
|
||||||
index,
|
index,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<'div'> & {
|
||||||
index: number;
|
index: number;
|
||||||
}) {
|
}) => {
|
||||||
const inputOTPContext = React.useContext(OTPInputContext);
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ function InputOTPSlot({
|
|||||||
data-slot='input-otp-slot'
|
data-slot='input-otp-slot'
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
|
'dark:bg-input/30 border-input data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive relative flex size-8 items-center justify-center border-y border-r text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg data-[active=true]:z-10 data-[active=true]:ring-3',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -64,14 +67,17 @@ function InputOTPSlot({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
const InputOTPSeparator = ({ ...props }: React.ComponentProps<'div'>) => (
|
||||||
return (
|
<div
|
||||||
<div data-slot='input-otp-separator' role='separator' {...props}>
|
data-slot='input-otp-separator'
|
||||||
|
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
role='separator'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<MinusIcon />
|
<MinusIcon />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import * as React from 'react';
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
const Input = ({
|
||||||
return (
|
className,
|
||||||
|
type,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'input'>) => (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
data-slot='input'
|
data-slot='input'
|
||||||
className={cn(
|
className={cn(
|
||||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm',
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export { Input };
|
export { Input };
|
||||||
|
|||||||
181
packages/ui/src/item.tsx
Normal file
181
packages/ui/src/item.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn, Separator } from '@gib/ui';
|
||||||
|
|
||||||
|
const ItemGroup = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
role='list'
|
||||||
|
data-slot='item-group'
|
||||||
|
className={cn(
|
||||||
|
'group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemSeparator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) => (
|
||||||
|
<Separator
|
||||||
|
data-slot='item-separator'
|
||||||
|
orientation='horizontal'
|
||||||
|
className={cn('my-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
'[a]:hover:bg-muted group/item focus-visible:border-ring focus-visible:ring-ring/50 flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent',
|
||||||
|
outline: 'border-border',
|
||||||
|
muted: 'bg-muted/50 border-transparent',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'gap-2.5 px-3 py-2.5',
|
||||||
|
sm: 'gap-2.5 px-3 py-2.5',
|
||||||
|
xs: 'gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Item = ({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> &
|
||||||
|
VariantProps<typeof itemVariants> & { asChild?: boolean }) => {
|
||||||
|
const Comp = asChild ? Slot.Root : 'div';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot='item'
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(itemVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
'flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
'size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemMedia = ({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) => (
|
||||||
|
<div
|
||||||
|
data-slot='item-media'
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemContent = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='item-content'
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemTitle = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='item-title'
|
||||||
|
className={cn(
|
||||||
|
'line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemDescription = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'p'>) => (
|
||||||
|
<p
|
||||||
|
data-slot='item-description'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground [&>a:hover]:text-primary line-clamp-2 text-left text-sm leading-normal font-normal group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemActions = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='item-actions'
|
||||||
|
className={cn('flex items-center gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='item-header'
|
||||||
|
className={cn(
|
||||||
|
'flex basis-full items-center justify-between gap-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='item-footer'
|
||||||
|
className={cn(
|
||||||
|
'flex basis-full items-center justify-between gap-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
};
|
||||||
22
packages/ui/src/kbd.tsx
Normal file
22
packages/ui/src/kbd.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const Kbd = ({ className, ...props }: React.ComponentProps<'kbd'>) => (
|
||||||
|
<kbd
|
||||||
|
data-slot='kbd'
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const KbdGroup = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<kbd
|
||||||
|
data-slot='kbd-group'
|
||||||
|
className={cn('inline-flex items-center gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup };
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import type * as React from 'react';
|
||||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
import { Label as LabelPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Label({
|
const Label = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) => (
|
||||||
return (
|
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
data-slot='label'
|
data-slot='label'
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -19,6 +18,4 @@ function Label({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export { Label };
|
export { Label };
|
||||||
|
|||||||
252
packages/ui/src/menubar.tsx
Normal file
252
packages/ui/src/menubar.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||||
|
import { Menubar as MenubarPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const Menubar = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Root>) => (
|
||||||
|
<MenubarPrimitive.Root
|
||||||
|
data-slot='menubar'
|
||||||
|
className={cn(
|
||||||
|
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarMenu = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) => (
|
||||||
|
<MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarGroup = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Group>) => (
|
||||||
|
<MenubarPrimitive.Group data-slot='menubar-group' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarPortal = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) => (
|
||||||
|
<MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarRadioGroup = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) => (
|
||||||
|
<MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarTrigger = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) => (
|
||||||
|
<MenubarPrimitive.Trigger
|
||||||
|
data-slot='menubar-trigger'
|
||||||
|
className={cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarContent = ({
|
||||||
|
className,
|
||||||
|
align = 'start',
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Content>) => (
|
||||||
|
<MenubarPortal>
|
||||||
|
<MenubarPrimitive.Content
|
||||||
|
data-slot='menubar-content'
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenubarPortal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarItem = ({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
}) => (
|
||||||
|
<MenubarPrimitive.Item
|
||||||
|
data-slot='menubar-item'
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive! relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarCheckboxItem = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) => (
|
||||||
|
<MenubarPrimitive.CheckboxItem
|
||||||
|
data-slot='menubar-checkbox-item'
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className='size-4' />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarRadioItem = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) => (
|
||||||
|
<MenubarPrimitive.RadioItem
|
||||||
|
data-slot='menubar-radio-item'
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className='size-2 fill-current' />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarLabel = ({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) => (
|
||||||
|
<MenubarPrimitive.Label
|
||||||
|
data-slot='menubar-label'
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarSeparator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) => (
|
||||||
|
<MenubarPrimitive.Separator
|
||||||
|
data-slot='menubar-separator'
|
||||||
|
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) => (
|
||||||
|
<span
|
||||||
|
data-slot='menubar-shortcut'
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarSub = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) => (
|
||||||
|
<MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarSubTrigger = ({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) => (
|
||||||
|
<MenubarPrimitive.SubTrigger
|
||||||
|
data-slot='menubar-sub-trigger'
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className='ml-auto h-4 w-4' />
|
||||||
|
</MenubarPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenubarSubContent = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) => (
|
||||||
|
<MenubarPrimitive.SubContent
|
||||||
|
data-slot='menubar-sub-content'
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
};
|
||||||
51
packages/ui/src/native-select.tsx
Normal file
51
packages/ui/src/native-select.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type * as React from 'react';
|
||||||
|
import { ChevronDownIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const NativeSelect = ({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<'select'>, 'size'> & {
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className='group/native-select relative w-fit has-[select:disabled]:opacity-50'
|
||||||
|
data-slot='native-select-wrapper'
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
data-slot='native-select'
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
'border-input selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed data-[size=sm]:h-8 data-[size=sm]:py-1',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className='text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none'
|
||||||
|
aria-hidden='true'
|
||||||
|
data-slot='native-select-icon'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NativeSelectOption = ({ ...props }: React.ComponentProps<'option'>) => (
|
||||||
|
<option data-slot='native-select-option' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const NativeSelectOptGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'optgroup'>) => (
|
||||||
|
<optgroup
|
||||||
|
data-slot='native-select-optgroup'
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
|
||||||
150
packages/ui/src/navigation-menu.tsx
Normal file
150
packages/ui/src/navigation-menu.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import type * as React from 'react';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { ChevronDownIcon } from 'lucide-react';
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const NavigationMenu = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean;
|
||||||
|
}) => (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot='navigation-menu'
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NavigationMenuList = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) => (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot='navigation-menu-list'
|
||||||
|
className={cn(
|
||||||
|
'group flex flex-1 list-none items-center justify-center gap-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NavigationMenuItem = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) => (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot='navigation-menu-item'
|
||||||
|
className={cn('relative', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
'group bg-background hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
);
|
||||||
|
|
||||||
|
const NavigationMenuTrigger = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) => (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot='navigation-menu-trigger'
|
||||||
|
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{' '}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className='relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180'
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NavigationMenuContent = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) => (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot='navigation-menu-content'
|
||||||
|
className={cn(
|
||||||
|
'data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
|
||||||
|
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NavigationMenuViewport = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) => (
|
||||||
|
<div
|
||||||
|
className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot='navigation-menu-viewport'
|
||||||
|
className={cn(
|
||||||
|
'origin-top-center bg-popover text-popover-foreground data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NavigationMenuLink = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) => (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot='navigation-menu-link'
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground data-[active=true]:hover:bg-accent data-[active=true]:focus:bg-accent [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NavigationMenuIndicator = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) => (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot='navigation-menu-indicator'
|
||||||
|
className={cn(
|
||||||
|
'data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className='bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md' />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
};
|
||||||
@@ -5,11 +5,9 @@ import {
|
|||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import type { Button } from '@gib/ui';
|
import { Button, cn } from '@gib/ui';
|
||||||
import { buttonVariants, cn } from '@gib/ui';
|
|
||||||
|
|
||||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||||
return (
|
|
||||||
<nav
|
<nav
|
||||||
role='navigation'
|
role='navigation'
|
||||||
aria-label='pagination'
|
aria-label='pagination'
|
||||||
@@ -18,110 +16,104 @@ function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationContent({
|
const PaginationContent = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'ul'>) {
|
}: React.ComponentProps<'ul'>) => (
|
||||||
return (
|
|
||||||
<ul
|
<ul
|
||||||
data-slot='pagination-content'
|
data-slot='pagination-content'
|
||||||
className={cn('flex flex-row items-center gap-1', className)}
|
className={cn('flex items-center gap-0.5', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
|
const PaginationItem = ({ ...props }: React.ComponentProps<'li'>) => (
|
||||||
return <li data-slot='pagination-item' {...props} />;
|
<li data-slot='pagination-item' {...props} />
|
||||||
}
|
);
|
||||||
|
|
||||||
type PaginationLinkProps = {
|
type PaginationLinkProps = {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
||||||
React.ComponentProps<'a'>;
|
React.ComponentProps<'a'>;
|
||||||
|
|
||||||
function PaginationLink({
|
const PaginationLink = ({
|
||||||
className,
|
className,
|
||||||
isActive,
|
isActive,
|
||||||
size = 'icon',
|
size = 'icon',
|
||||||
...props
|
...props
|
||||||
}: PaginationLinkProps) {
|
}: PaginationLinkProps) => (
|
||||||
return (
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={isActive ? 'outline' : 'ghost'}
|
||||||
|
size={size}
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
data-slot='pagination-link'
|
data-slot='pagination-link'
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: isActive ? 'outline' : 'ghost',
|
|
||||||
size,
|
|
||||||
}),
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationPrevious({
|
const PaginationPrevious = ({
|
||||||
className,
|
className,
|
||||||
|
text = 'Previous',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PaginationLink>) {
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) => (
|
||||||
return (
|
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label='Go to previous page'
|
aria-label='Go to previous page'
|
||||||
size='default'
|
size='default'
|
||||||
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
|
className={cn('pl-1.5!', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon data-icon='inline-start' className='cn-rtl-flip' />
|
||||||
<span className='hidden sm:block'>Previous</span>
|
<span className='hidden sm:block'>{text}</span>
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationNext({
|
const PaginationNext = ({
|
||||||
className,
|
className,
|
||||||
|
text = 'Next',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PaginationLink>) {
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) => (
|
||||||
return (
|
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label='Go to next page'
|
aria-label='Go to next page'
|
||||||
size='default'
|
size='default'
|
||||||
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
|
className={cn('pr-1.5!', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className='hidden sm:block'>Next</span>
|
<span className='hidden sm:block'>{text}</span>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon data-icon='inline-end' className='cn-rtl-flip' />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationEllipsis({
|
const PaginationEllipsis = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'>) {
|
}: React.ComponentProps<'span'>) => (
|
||||||
return (
|
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
data-slot='pagination-ellipsis'
|
data-slot='pagination-ellipsis'
|
||||||
className={cn('flex size-9 items-center justify-center', className)}
|
className={cn(
|
||||||
|
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<MoreHorizontalIcon className='size-4' />
|
<MoreHorizontalIcon />
|
||||||
<span className='sr-only'>More pages</span>
|
<span className='sr-only'>More pages</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
PaginationLink,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationPrevious,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationEllipsis,
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
};
|
};
|
||||||
|
|||||||
84
packages/ui/src/popover.tsx
Normal file
84
packages/ui/src/popover.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { Popover as PopoverPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const Popover = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) => (
|
||||||
|
<PopoverPrimitive.Root data-slot='popover' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const PopoverTrigger = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) => (
|
||||||
|
<PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const PopoverContent = ({
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot='popover-content'
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PopoverAnchor = ({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) => (
|
||||||
|
<PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const PopoverHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='popover-header'
|
||||||
|
className={cn('flex flex-col gap-1 text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PopoverTitle = ({ className, ...props }: React.ComponentProps<'h2'>) => (
|
||||||
|
<div
|
||||||
|
data-slot='popover-title'
|
||||||
|
className={cn('font-medium', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PopoverDescription = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'p'>) => (
|
||||||
|
<p
|
||||||
|
data-slot='popover-description'
|
||||||
|
className={cn('text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverDescription,
|
||||||
|
};
|
||||||
@@ -1,31 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
import { Progress as ProgressPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import { cn } from '@gib/ui';
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
function Progress({
|
const Progress = ({
|
||||||
className,
|
className,
|
||||||
value,
|
value,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) => (
|
||||||
return (
|
|
||||||
<ProgressPrimitive.Root
|
<ProgressPrimitive.Root
|
||||||
data-slot='progress'
|
data-slot='progress'
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
'bg-muted relative flex h-1 w-full items-center overflow-x-hidden rounded-full',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ProgressPrimitive.Indicator
|
<ProgressPrimitive.Indicator
|
||||||
data-slot='progress-indicator'
|
data-slot='progress-indicator'
|
||||||
className='bg-primary h-full w-full flex-1 transition-all'
|
className='bg-primary size-full flex-1 transition-all'
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export { Progress };
|
export { Progress };
|
||||||
|
|||||||
41
packages/ui/src/radio-group.tsx
Normal file
41
packages/ui/src/radio-group.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type * as React from 'react';
|
||||||
|
import { CircleIcon } from 'lucide-react';
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@gib/ui';
|
||||||
|
|
||||||
|
const RadioGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) => (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot='radio-group'
|
||||||
|
className={cn('grid gap-3', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RadioGroupItem = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) => (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot='radio-group-item'
|
||||||
|
className={cn(
|
||||||
|
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot='radio-group-indicator'
|
||||||
|
className='relative flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<CircleIcon className='fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2' />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user