Compare commits

..

18 Commits

Author SHA1 Message Date
01cdd5ab4f Formatting 2026-03-26 12:36:48 -05:00
ff8c39dc75 Update expo app to make it somewhat functional 2026-03-26 12:36:18 -05:00
56fe2a2af3 Update some thangs 2026-03-26 12:22:16 -05:00
d16f4287ce Update AGENTS.md. Start fixing old weird errors 2026-03-26 12:05:12 -05:00
0bc04dbf6b Clean up exports now that we are leaning into the gibui thing 2026-03-26 10:49:12 -05:00
6e78140103 Remove two convex functions that came from tech tracker that we shouldn't need 2026-03-26 10:08:45 -05:00
8c62780dcb Update the schema.ts to be more like the one on stpeteit 2026-03-26 10:02:21 -05:00
2d0a34347b I think we might finally have the env crap figured out 2026-03-21 17:23:09 -05:00
5e37d10300 I think we might finally have the env crap figured out 2026-03-21 17:20:57 -05:00
1e61e34fb8 Trying to get stuff working 2026-03-21 16:22:46 -05:00
07dc8d7976 Fix next.config.js 2026-03-21 16:08:42 -05:00
ee99ab11c9 Update env stuff because I don't think it was all working right. 2026-03-21 16:07:50 -05:00
0ecf6238de Image finally builds. Not perfect though 2026-03-20 19:46:30 -05:00
60dc57ddf7 Update last minute things before rebuilding 2026-03-20 18:34:33 -05:00
a8bb610be7 Get password auth working. 2026-03-20 17:41:19 -05:00
81e6a5aaa6 Add that thing 2026-03-20 13:50:02 -05:00
d2eea9880a update packages 2026-03-20 13:47:53 -05:00
a11af16346 Updated a few package.jsons that should be safe 2026-03-20 13:37:52 -05:00
128 changed files with 31600 additions and 8961 deletions

View File

@@ -18,8 +18,9 @@ out
.git
.gitignore
*.log
.env.local
.env*.local
#.env
#.env.*
!.env.example
.vscode
.idea

View File

@@ -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_ADMIN_KEY= # Generate after hosted on docker
# 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_URL=https://usesend.example.com
USESEND_FROM_EMAIL=My App <noreply@example.com>
AUTH_AUTHENTIK_ID=
AUTH_AUTHENTIK_SECRET=
AUTH_AUTHENTIK_ISSUER=

0
.npmrc
View File

1663
AGENTS.md

File diff suppressed because it is too large Load Diff

287
README.md
View File

@@ -32,15 +32,21 @@ A production-ready Turborepo starter with Next.js, Expo, and self-hosted Convex
## 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
- [Bun](https://bun.sh) (v1.2.19+)
- [Bun](https://bun.sh) (v1.2+)
- [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
git clone https://git.gbrown.org/gib/convex-monorepo
@@ -48,88 +54,152 @@ cd convex-monorepo
bun install
```
#### 2. Configure Environment Variables
Create a `.env` file in the project root with the following variables:
If you're using this as a template for a new project, remove the existing remote and
add your own:
```bash
# Convex Backend (Self-Hosted)
CONVEX_SELF_HOSTED_URL=https://api.convex.example.com
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=
git remote remove origin
git remote add origin https://your-git-host.com/your/new-repo.git
```
**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
cd docker/
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
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
- **Dashboard:** http://localhost:6791
```bash
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
cd packages/backend
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
cd packages/backend
bun with-env npx convex env set AUTH_AUTHENTIK_ID "your-value"
bun with-env npx convex env set AUTH_AUTHENTIK_SECRET "your-value"
# From packages/backend/
bun with-env npx convex env set JWT_PRIVATE_KEY "your-private-key"
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 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
# From project root
bun dev:next # Next.js app + Convex backend
bun dev:next # Next.js app + Convex backend (most common)
# or
bun dev # All apps (Next.js + Expo + Backend)
```
@@ -137,7 +207,7 @@ bun dev # All apps (Next.js + Expo + Backend)
**App URLs:**
- **Next.js:** http://localhost:3000
- **Convex Dashboard:** http://localhost:6791
- **Convex Dashboard:** https://dashboard.convex.example.com
---
@@ -193,7 +263,7 @@ convex-monorepo/
├── packages/
│ ├── backend/ # Convex backend
│ │ ├── convex/ # Convex functions (synced to cloud)
│ │ ├── convex/ # Convex functions (synced to deployment)
│ │ ├── scripts/ # Utilities (generateKeys.mjs)
│ │ └── types/ # Shared types
│ └── ui/ # shadcn/ui components
@@ -223,16 +293,16 @@ convex-monorepo/
- **OAuth:** Authentik SSO integration
- **Password:** Custom password auth with email verification
- **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
- **App Router:** Next.js 16 with React Server Components
- **Data Preloading:** Server-side data fetching with Convex
- **Middleware:** Route protection & authentication
- **Styling:** Tailwind CSS v4 with dark mode
- **Analytics:** Plausible (privacy-focused)
- **Monitoring:** Sentry error tracking
- **Data Preloading:** SSR data fetching with `preloadQuery` + `usePreloadedQuery`
- **Middleware:** Route protection & IP-based security (`src/proxy.ts`)
- **Styling:** Tailwind CSS v4 with dark mode (OKLCH-based theme)
- **Analytics:** Plausible (privacy-focused, proxied through Next.js)
- **Monitoring:** Sentry error tracking & performance
### Backend
@@ -244,87 +314,113 @@ convex-monorepo/
### Developer Experience
- **Monorepo:** Turborepo for efficient builds
- **Monorepo:** Turborepo for efficient builds and caching
- **Type Safety:** Strict TypeScript throughout
- **Code Quality:** ESLint + Prettier with auto-fix
- **Hot Reload:** Fast refresh for all packages
- **Catalog Deps:** Centralized version management
- **Catalog Deps:** Centralized dependency version management
---
## 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
cd docker/
# Start all services
docker compose up -d
# View logs
docker compose logs -f
# Stop services
docker compose down
sudo docker compose up -d convex-backend convex-dashboard
# Wait for backend health check to pass, then:
sudo docker compose up -d next-app
```
**Services:**
- `next-app` - Next.js standalone build
- `convex-backend` - Convex backend (port 3210)
- `convex-dashboard` - Admin dashboard (port 6791)
- `next-app` Next.js standalone build
- `convex-backend` Convex backend (port 3210)
- `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
- [ ] Update environment variables in `docker/.env`
- [ ] Generate `CONVEX_SELF_HOSTED_ADMIN_KEY`
- [ ] Configure reverse proxy (Nginx/Traefik)
- [ ] Set up SSL certificates
- [ ] Sync auth environment variables to Convex
- [ ] Configure backup strategy for `docker/data/`
- [ ] Test authentication flow
- [ ] Enable Sentry error tracking
- [ ] Fill out `docker/.env` with your domain names and secrets
- [ ] Start `convex-backend` and `convex-dashboard` containers
- [ ] Generate and set `CONVEX_SELF_HOSTED_ADMIN_KEY` via `./generate_convex_admin_key`
- [ ] Reverse-proxy both Convex services via nginx-proxy-manager with SSL
- [ ] Fill out root `/.env` with all environment variables
- [ ] Generate JWT keys and sync all env vars to Convex (`bun with-env npx convex env set ...`)
- [ ] Update `CONVEX_SITE_URL` in the Convex Dashboard to your production Next.js URL
- [ ] Build and start the `next-app` container
- [ ] Back up `docker/data/` regularly (contains all Convex database data)
---
## Documentation
- **[AGENTS.md](./AGENTS.md)** - Comprehensive guide for AI agents & developers
- **[Convex Docs](https://docs.convex.dev)** - Official Convex documentation
- **[Turborepo Docs](https://turbo.build/repo/docs)** - Turborepo documentation
- **[Next.js Docs](https://nextjs.org/docs)** - Next.js documentation
- **[AGENTS.md](./AGENTS.md)** Comprehensive guide for AI agents & developers
- **[Convex Docs](https://docs.convex.dev)** Official Convex documentation
- **[Turborepo Docs](https://turbo.build/repo/docs)** Turborepo documentation
- **[Next.js Docs](https://nextjs.org/docs)** Next.js documentation
---
## 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
// ✅ Correct
// ❌ Wrong
import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
// ❌ Wrong — will fail at runtime
import { api } from '@gib/backend/convex/_generated/api';
import { api } from '@gib/backend/convex/_generated/api.js';
```
### 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`
3. Ensure ports 3210 and 6791 are available
4. Check network configuration (`nginx-bridge`)
3. Ensure the `nginx-bridge` network exists: `sudo docker network create 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
- 80 character line width
- ESLint + Prettier enforced
- `const fn = () => {}` over `function fn()` (strong preference)
- Import order: Types → React → Next → Third-party → @gib → Local
Run `bun lint:fix` and `bun format:fix` before committing.

View File

@@ -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"]

View File

@@ -6,7 +6,7 @@
"build": {
"base": {
"node": "22.12.0",
"pnpm": "9.15.4",
"bun": "1.3.10",
"ios": {
"resourceClass": "m-medium"
}

View File

@@ -1,7 +1,8 @@
import { baseConfig } from '@acme/eslint-config/base';
import { reactConfig } from '@acme/eslint-config/react';
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@gib/eslint-config/base';
import { reactConfig } from '@gib/eslint-config/react';
export default defineConfig(
{
ignores: ['.expo/**', 'expo-plugins/**'],

View File

@@ -16,41 +16,40 @@
},
"dependencies": {
"@convex-dev/auth": "catalog:convex",
"@expo/vector-icons": "^15.0.3",
"@expo/vector-icons": "^15.1.1",
"@gib/backend": "workspace:*",
"@legendapp/list": "^2.0.14",
"@react-navigation/bottom-tabs": "^7.6.0",
"@react-navigation/elements": "^2.7.1",
"@react-navigation/native": "^7.1.19",
"@sentry/react-native": "^7.4.0",
"@legendapp/list": "^2.0.19",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10",
"@react-navigation/native": "^7.1.33",
"@sentry/react-native": "^7.13.0",
"convex": "catalog:convex",
"expo": "~54.0.20",
"expo-apple-authentication": "~8.0.7",
"expo-constants": "~18.0.10",
"expo-dev-client": "~6.0.16",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.13",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8",
"expo": "~54.0.33",
"expo-apple-authentication": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"nativewind": "5.0.0-preview.2",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"react-native": "~0.81.5",
"react-native": "~0.81.6",
"react-native-css": "3.0.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.3",
"react-native-safe-area-context": "~5.6.1",
"react-native-reanimated": "~4.1.6",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.2",
"react-native-worklets": "~0.5.1",
"superjson": "2.2.3"
"react-native-worklets": "~0.5.2"
},
"devDependencies": {
"@gib/eslint-config": "workspace:*",

View File

@@ -1 +1 @@
module.exports = require('@acme/tailwind-config/postcss-config');
module.exports = require('@gib/tailwind-config/postcss-config');

View File

@@ -1,33 +1,30 @@
import { useColorScheme } from 'react-native';
import { Stack } from 'expo-router';
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';
// This is the main layout of the app
// It wraps your pages with the providers they need
export default function RootLayout() {
const RootLayout = () => {
const colorScheme = useColorScheme();
return (
<QueryClientProvider client={queryClient}>
{/*
The Stack component displays the current page.
It also allows you to configure your screens
*/}
<ConvexAuthProvider client={convex}>
<Stack
screenOptions={{
headerStyle: {
backgroundColor: '#c03484',
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
},
headerTintColor: colorScheme === 'dark' ? '#fafaf9' : '#1c1917',
contentStyle: {
backgroundColor: colorScheme == 'dark' ? '#09090B' : '#FFFFFF',
backgroundColor: colorScheme === 'dark' ? '#1c1917' : '#faf9f7',
},
}}
/>
<StatusBar />
</QueryClientProvider>
<StatusBar style='auto' />
</ConvexAuthProvider>
);
}
};
export default RootLayout;

View File

@@ -1,172 +1,54 @@
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Link, Stack } from 'expo-router';
import { LegendList } from '@legendapp/list';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Stack } from 'expo-router';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
import type { RouterOutputs } from '~/utils/api';
import { trpc } from '~/utils/api';
import { authClient } from '~/utils/auth';
import { api } from '@gib/backend/convex/_generated/api.js';
const Index = () => {
const { isAuthenticated, isLoading } = useConvexAuth();
const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
function PostCard(props: {
post: RouterOutputs['post']['all'][number];
onDelete: () => void;
}) {
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}
<SafeAreaView className='bg-background flex-1'>
<Stack.Screen options={{ title: 'Home' }} />
<View className='flex-1 items-center justify-center gap-4 p-6'>
<Text className='text-foreground text-4xl font-bold'>
Convex Monorepo
</Text>
<Text className='text-muted-foreground text-center text-base'>
Your self-hosted Expo + Convex starter
</Text>
{isLoading ? (
<Text className='text-muted-foreground'>Loading...</Text>
) : isAuthenticated ? (
<View className='w-full gap-3'>
<Text className='text-foreground text-center text-lg'>
Welcome{user?.name ? `, ${user.name}` : ''}!
</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 (
<View className='mt-4 flex gap-2'>
<TextInput
className='border-input bg-background text-foreground items-center rounded-md border px-3 text-lg leading-tight'
value={title}
onChangeText={setTitle}
placeholder='Title'
/>
{error?.data?.zodError?.fieldErrors.title && (
<Text className='text-destructive mb-2'>
{error.data.zodError.fieldErrors.title}
</Text>
)}
<TextInput
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>
)}
<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() {
const { data: session } = authClient.useSession();
return (
<>
<Text className='text-foreground pb-2 text-center text-xl font-semibold'>
{session?.user.name ? `Hello, ${session.user.name}` : 'Not logged in'}
</Text>
<Pressable
onPress={() =>
session
? 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>
</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>
<LegendList
data={postQuery.data ?? []}
estimatedItemSize={20}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View className='h-2' />}
renderItem={(p) => (
<PostCard
post={p.item}
onDelete={() => deletePostMutation.mutate(p.item.id)}
/>
)}
/>
<CreatePost />
<Pressable
className='bg-primary items-center rounded-md p-3'
onPress={() => void signOut()}
>
<Text className='text-primary-foreground font-semibold'>
Sign Out
</Text>
</Pressable>
</View>
) : (
<View className='w-full gap-3'>
<Text className='text-muted-foreground text-center'>
Sign in to get started
</Text>
{/* Add sign-in UI here — see apps/next/src/app/(auth)/sign-in for patterns */}
</View>
)}
</View>
</SafeAreaView>
);
}
};
export default Index;

View File

@@ -1,24 +1,21 @@
import { SafeAreaView, Text, View } from 'react-native';
import { Stack, useGlobalSearchParams } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Stack, useLocalSearchParams } from 'expo-router';
import { trpc } from '~/utils/api';
export default function Post() {
const { id } = useGlobalSearchParams<{ id: string }>();
const { data } = useQuery(trpc.post.byId.queryOptions({ id }));
if (!data) return null;
const Post = () => {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<SafeAreaView className='bg-background'>
<Stack.Screen options={{ title: data.title }} />
<View className='h-full w-full p-4'>
<Text className='text-primary py-2 text-3xl font-bold'>
{data.title}
<SafeAreaView className='bg-background flex-1'>
<Stack.Screen options={{ title: 'Post' }} />
<View className='flex-1 p-4'>
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
<Text className='text-muted-foreground mt-2'>
Implement your post detail screen here using Convex queries.
</Text>
<Text className='text-foreground py-4'>{data.content}</Text>
</View>
</SafeAreaView>
);
}
};
export default Post;

View File

@@ -1,3 +1,3 @@
@import 'tailwindcss';
@import 'nativewind/theme';
@import '@acme/tailwind-config/theme';
@import '@gib/tailwind-config/theme';

View File

@@ -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';

View File

@@ -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,
}),
],
});

View 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());

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://turborepo.com/schema.json",
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
"extends": ["//"],
"tasks": {
"dev": {

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

View File

@@ -1,11 +1,13 @@
import { withSentryConfig } from '@sentry/nextjs';
import { createJiti } from 'jiti';
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} */
const config = withPlausibleProxy({
customDomain: env.NEXT_PUBLIC_PLAUSIBLE_URL,
customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
})({
output: 'standalone',
images: {
@@ -30,12 +32,12 @@ const config = withPlausibleProxy({
const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: env.NEXT_PUBLIC_SENTRY_ORG,
project: env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: env.NEXT_PUBLIC_SENTRY_URL,
authToken: env.SENTRY_AUTH_TOKEN,
org: process.env.NEXT_PUBLIC_SENTRY_ORG,
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !env.CI,
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)

View File

@@ -5,6 +5,7 @@
"private": true,
"scripts": {
"build": "bun with-env next build",
"build:env": "bun with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "bun with-env next dev --turbo",
"dev:tunnel": "bun with-env next dev --turbo",
@@ -18,11 +19,11 @@
"@convex-dev/auth": "catalog:convex",
"@gib/backend": "workspace:*",
"@gib/ui": "workspace:*",
"@sentry/nextjs": "^10.22.0",
"@t3-oss/env-nextjs": "^0.13.8",
"@sentry/nextjs": "^10.43.0",
"@t3-oss/env-nextjs": "^0.13.10",
"convex": "catalog:convex",
"next": "^16.0.0",
"next-plausible": "^3.12.4",
"next": "^16.1.7",
"next-plausible": "^3.12.5",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"require-in-the-middle": "^7.5.2",

View File

@@ -33,7 +33,7 @@ const Profile = async () => {
{/* Profile Card */}
<Card className='border-border/40'>
<ProfileHeader preloadedUser={preloadedUser} />
<ProfileHeader />
<AvatarUpload preloadedUser={preloadedUser} />
<Separator className='my-6' />
<UserInfoForm

View File

@@ -252,24 +252,27 @@ const SignIn = () => {
<Tabs
defaultValue={flow}
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
className='items-center'
className='flex-col items-center'
>
<TabsList className='py-6'>
<TabsList>
<TabsTrigger
value='signIn'
className='cursor-pointer p-6 text-2xl font-bold'
className='cursor-pointer px-6 py-2 text-2xl font-bold'
>
Sign In
</TabsTrigger>
<TabsTrigger
value='signUp'
className='cursor-pointer p-6 text-2xl font-bold'
className='cursor-pointer px-6 py-2 text-2xl font-bold'
>
Sign Up
</TabsTrigger>
</TabsList>
<TabsContent value='signIn'>
<Card className='bg-card/50 min-w-xs sm:min-w-sm'>
<TabsContent
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>
<Form {...signInForm}>
<form

View File

@@ -10,6 +10,7 @@ import { useEffect } from 'react';
import Footer from '@/components/layout/footer';
import Header from '@/components/layout/header';
import { ConvexClientProvider } from '@/components/providers';
import { env } from '@/env';
import { generateMetadata } from '@/lib/metadata';
import * as Sentry from '@sentry/nextjs';
import PlausibleProvider from 'next-plausible';
@@ -45,8 +46,8 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
}, [error]);
return (
<PlausibleProvider
domain='convexmonorepo.gbrown.org'
customDomain='https://plausible.gbrown.org'
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en' suppressHydrationWarning>
<body

View File

@@ -39,10 +39,10 @@ const RootLayout = ({
return (
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
domain={env.NEXT_PUBLIC_SITE_URL}
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
>
<html lang='en'>
<html lang='en' suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@@ -1,38 +1,31 @@
import Image from 'next/image';
import Link from 'next/link';
export const CTA = () => (
<section className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-4xl'>
<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'>
Ready to Build Something Amazing?
</h2>
<p className='text-muted-foreground mb-8 text-lg'>
Clone the repository and start building your next project with
everything pre-configured.
</p>
import { Button } from '@gib/ui/button';
export function CTA() {
return (
<section className='container mx-auto px-4 py-24'>
<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'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
Ready to Build Something Amazing?
</h2>
<p className='text-muted-foreground mb-8 text-lg'>
Clone the repository and start building your next project with
everything pre-configured.
{/* Quick Start Command */}
<div className='mt-12'>
<p className='text-muted-foreground mb-3 text-sm font-medium'>
Quick Start
</p>
{/* Quick Start Command */}
<div className='mt-12'>
<p className='text-muted-foreground mb-3 text-sm font-medium'>
Quick Start
</p>
<div className='border-border/40 bg-background mx-auto max-w-2xl rounded-lg border p-4'>
<code className='text-sm'>
git clone https://git.gbrown.org/gib/convex-monorepo.git
<br />
cd convex-monorepo
<br />
bun i
</code>
</div>
<div className='border-border/40 bg-background mx-auto max-w-2xl rounded-lg border p-4'>
<code className='text-sm'>
git clone https://git.gbrown.org/gib/convex-monorepo.git
<br />
cd convex-monorepo
<br />
bun i
</code>
</div>
</div>
</div>
</section>
);
}
</div>
</section>
);

View File

@@ -1,4 +1,4 @@
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@gib/ui';
const features = [
{
@@ -57,36 +57,34 @@ const features = [
},
];
export function Features() {
return (
<section id='features' className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Everything You Need to Ship Fast
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
A complete monorepo template with all the tools and patterns you
need for production-ready applications.
</p>
</div>
{/* Features Grid */}
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
{features.map((feature) => (
<Card key={feature.title} className='border-border/40'>
<CardHeader className='flex items-center gap-2'>
<div className='mb-2 text-3xl'>{feature.icon}</div>
<CardTitle className='text-xl'>{feature.title}</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground'>{feature.description}</p>
</CardContent>
</Card>
))}
</div>
export const Features = () => (
<section id='features' className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Everything You Need to Ship Fast
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
A complete monorepo template with all the tools and patterns you need
for production-ready applications.
</p>
</div>
</section>
);
}
{/* Features Grid */}
<div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
{features.map((feature) => (
<Card key={feature.title} className='border-border/40'>
<CardHeader className='flex items-center gap-2'>
<div className='mb-2 text-3xl'>{feature.icon}</div>
<CardTitle className='text-xl'>{feature.title}</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground'>{feature.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);

View File

@@ -2,127 +2,125 @@ import { Kanit } from 'next/font/google';
import Image from 'next/image';
import Link from 'next/link';
import { Button } from '@gib/ui/button';
import { Button } from '@gib/ui';
const kanitSans = Kanit({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
});
export function Hero() {
return (
<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'>
{/* Badge */}
<div className='border-border/40 bg-muted/50 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium'>
<span className='mr-2'>🚀</span>
<span>Production-ready monorepo template</span>
</div>
export const Hero = () => (
<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'>
{/* Badge */}
<div className='border-border/40 bg-muted/50 inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium'>
<span className='mr-2'>🚀</span>
<span>Production-ready monorepo template</span>
</div>
{/* Heading */}
<h1 className='from-foreground to-foreground/70 bg-linear-to-br bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl md:text-6xl lg:text-7xl'>
Build Full-Stack Apps with{' '}
<span
className={`${kanitSans.className} to-accent-foreground bg-linear-to-r from-[#281A65] via-[#363354] bg-clip-text text-transparent sm:text-6xl lg:text-7xl xl:text-8xl dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]`}
{/* Heading */}
<h1 className='from-foreground to-foreground/70 bg-linear-to-br bg-clip-text text-4xl font-bold tracking-tight text-transparent sm:text-5xl md:text-6xl lg:text-7xl'>
Build Full-Stack Apps with{' '}
<span
className={`${kanitSans.className} to-accent-foreground bg-linear-to-r from-[#281A65] via-[#363354] bg-clip-text text-transparent sm:text-6xl lg:text-7xl xl:text-8xl dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]`}
>
convex monorepo
</span>
</h1>
{/* Description */}
<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 web
and mobile apps faster with shared code, type-safe backend, and complete
control over your infrastructure.
</p>
{/* CTA Buttons */}
<div className='flex flex-col gap-3 sm:flex-row'>
<Button size='lg' variant='outline' asChild>
<Link
href='https://git.gbrown.org/gib/convex-monorepo'
target='_blank'
rel='noopener noreferrer'
>
convex monorepo
</span>
</h1>
<Image
src='/misc/gitea/gitea.svg'
alt='Gitea'
width={20}
height={20}
/>
View Source Code
</Link>
</Button>
</div>
{/* Description */}
<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
web and mobile apps faster with shared code, type-safe backend, and
complete control over your infrastructure.
</p>
{/* CTA Buttons */}
<div className='flex flex-col gap-3 sm:flex-row'>
<Button size='lg' variant='outline' asChild>
<Link
href='https://git.gbrown.org/gib/convex-monorepo'
target='_blank'
rel='noopener noreferrer'
>
<Image
src='/misc/gitea/gitea.svg'
alt='Gitea'
width={20}
height={20}
/>
View Source Code
</Link>
</Button>
{/* Features Quick List */}
<div className='text-muted-foreground mt-8 flex flex-wrap items-center justify-center gap-6 text-sm'>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>TypeScript</span>
</div>
{/* Features Quick List */}
<div className='text-muted-foreground mt-8 flex flex-wrap items-center justify-center gap-6 text-sm'>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>TypeScript</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Self-Hosted</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Real-time</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Auth Included</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Self-Hosted</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Real-time</span>
</div>
<div className='flex items-center gap-2'>
<svg
className='h-5 w-5 text-green-500'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span>Auth Included</span>
</div>
</div>
</section>
);
}
</div>
</section>
);

View File

@@ -36,44 +36,42 @@ const techStack = [
},
];
export function TechStack() {
return (
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Modern Tech Stack
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
Built with the latest and greatest tools for maximum productivity
and performance.
</p>
</div>
export const TechStack = () => (
<section id='tech-stack' className='border-border/40 bg-muted/30 border-t'>
<div className='container mx-auto px-4 py-24'>
<div className='mx-auto max-w-6xl'>
{/* Section Header */}
<div className='mb-16 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl'>
Modern Tech Stack
</h2>
<p className='text-muted-foreground mx-auto max-w-2xl text-lg'>
Built with the latest and greatest tools for maximum productivity
and performance.
</p>
</div>
{/* Tech Stack Grid */}
<div className='grid gap-12 md:grid-cols-3'>
{techStack.map((stack) => (
<div key={stack.category}>
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
<ul className='space-y-4'>
{stack.technologies.map((tech) => (
<li key={tech.name}>
<div className='text-foreground font-medium'>
{tech.name}
</div>
<div className='text-muted-foreground text-sm'>
{tech.description}
</div>
</li>
))}
</ul>
</div>
))}
</div>
{/* Tech Stack Grid */}
<div className='grid gap-12 md:grid-cols-3'>
{techStack.map((stack) => (
<div key={stack.category}>
<h3 className='mb-6 text-xl font-semibold'>{stack.category}</h3>
<ul className='space-y-4'>
{stack.technologies.map((tech) => (
<li key={tech.name}>
<div className='text-foreground font-medium'>
{tech.name}
</div>
<div className='text-muted-foreground text-sm'>
{tech.description}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</section>
);
}
</div>
</section>
);

View File

@@ -1,17 +1,8 @@
'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';
interface ProfileCardProps {
preloadedUser: Preloaded<typeof api.auth.getUser>;
}
const ProfileHeader = ({ preloadedUser }: ProfileCardProps) => {
const user = usePreloadedQuery(preloadedUser);
const ProfileHeader = () => {
return (
<CardHeader>
<CardTitle className='text-xl'>Account Settings</CardTitle>

View File

@@ -51,6 +51,10 @@ export const UserInfoForm = ({
}: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser);
const userProvider = usePreloadedQuery(preloadedProvider);
const providerMap: Record<string, string> = {
unknown: 'Provider',
authentik: "Gib's Auth",
};
const [loading, setLoading] = useState(false);
const updateUser = useMutation(api.auth.updateUser);
@@ -137,16 +141,17 @@ export const UserInfoForm = ({
{...field}
type='email'
placeholder='john@example.com'
disabled={userProvider !== 'email'}
disabled={userProvider !== 'password'}
/>
</FormControl>
{userProvider === 'email' ? (
{userProvider === 'password' ? (
<FormDescription>
Your email address for account notifications
</FormDescription>
) : (
<FormDescription>
Email is managed through your {userProvider} account
Email is managed through your{' '}
{providerMap[userProvider ?? 'unknown']} account
</FormDescription>
)}
<FormMessage />

View File

@@ -28,7 +28,7 @@ export default function Header(headerProps: ComponentProps<'header'>) {
alt='Convex Monorepo'
width={50}
height={50}
className='invert dark:invert-0'
className='w-15 invert dark:invert-0'
/>
<span
className={`mb-3 hidden font-extrabold lg:inline lg:text-5xl ${kanitSans.className}`}

View File

@@ -7,10 +7,8 @@ import { ConvexReactClient } from 'convex/react';
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ConvexAuthNextjsProvider client={convex}>
{children}
</ConvexAuthNextjsProvider>
);
}
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
<ConvexAuthNextjsProvider client={convex}>
{children}
</ConvexAuthNextjsProvider>
);

View File

@@ -2,17 +2,13 @@ import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod/v4';
export const env = createEnv({
shared: {
server: {
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
},
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
SKIP_ENV_VALIDATION: z.boolean().default(false),
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.
*/
experimental__runtimeEnv: {
runtimeEnv: {
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,
SITE_URL: process.env.SITE_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
@@ -44,6 +41,6 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_PROJECT_NAME:
process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME,
},
skipValidation:
!!process.env.CI || process.env.npm_lifecycle_event === 'lint',
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
});

View File

@@ -1,8 +1,7 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import { env } from '@/env';
import * as Sentry from '@sentry/nextjs';
import { env } from './env.js';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
integrations: [
@@ -20,7 +19,7 @@ Sentry.init({
tracesSampleRate: 1,
enableLogs: true,
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.5,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 1.0,
debug: false,
});

View File

@@ -1,7 +1,6 @@
import { env } from '@/env';
import * as Sentry from '@sentry/nextjs';
import { env } from './env.js';
Sentry.init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,

View File

@@ -3,6 +3,7 @@
"compilerOptions": {
"lib": ["ES2022", "dom", "dom.iterable"],
"jsx": "preserve",
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
},

1483
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,9 @@
# Next Envrionment Variables
NODE_ENV=production
NETWORK=nginx-bridge
NEXT_CONTAINER_NAME=next-app
NEXT_DOMAIN_NAME=gbrown.org
# Port is disabled by default as suggested
# config is to have reverse proxy on the same
# network so you can just forward to the
# port on the internal network.
# NEXT_PORT=3000
NEXT_PORT=3000
NODE_ENV=production
SENTRY_AUTH_TOKEN=
NEXT_PUBLIC_SITE_URL=https://gbrown.org
NEXT_PUBLIC_CONVEX_URL=https://api.convex.gbrown.org

View File

@@ -18,7 +18,7 @@ ENV NODE_ENV=production
RUN bun run build --filter=@gib/next
# Runner stage
FROM node:20-alpine AS runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

View File

@@ -9,16 +9,8 @@ services:
dockerfile: ./docker/Dockerfile
image: ${NEXT_CONTAINER_NAME}:alpine
container_name: ${NEXT_CONTAINER_NAME}
env_file: [.env]
environment:
- SENTRY_AUTH_TOKEN
- NEXT_PUBLIC_SITE_URL
- NEXT_PUBLIC_CONVEX_URL
- NEXT_PUBLIC_PLAUSIBLE_URL
- NEXT_PUBLIC_SENTRY_DSN
- NEXT_PUBLIC_SENTRY_URL
- NEXT_PUBLIC_SENTRY_ORG
- NEXT_PUBLIC_SENTRY_PROJECT_NAME
- NODE_ENV
hostname: ${NEXT_CONTAINER_NAME}
domainname: ${NEXT_DOMAIN_NAME}
networks: ['${NETWORK:-nginx-bridge}']
@@ -38,7 +30,6 @@ services:
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
volumes: [./data:/convex/data]
labels: ['com.centurylinklabs.watchtower.enable=true']
env_file: ['.env']
environment:
- INSTANCE_NAME
- INSTANCE_SECRET
@@ -67,7 +58,6 @@ services:
#user: 1000:1000
#ports: ['${DASHBOARD_PORT:-6791}:6791']
labels: ['com.centurylinklabs.watchtower.enable=true']
env_file: [.env]
environment:
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-convex-backend}:${PORT:-3210}}
depends_on:

View File

@@ -13,17 +13,17 @@
"catalog": {
"@eslint/js": "^9.38.0",
"@tailwindcss/postcss": "^4.1.16",
"@types/node": "^22.18.12",
"eslint": "^9.38.0",
"prettier": "^3.6.2",
"@types/node": "^22.19.15",
"eslint": "^9.39.4",
"prettier": "^3.8.1",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"zod": "^4.1.12"
"zod": "^4.3.6"
},
"catalogs": {
"convex": {
"@convex-dev/auth": "^0.0.81",
"convex": "^1.28.0"
"convex": "^1.33.1"
},
"react19": {
"@types/react": "~19.1.0",
@@ -55,12 +55,18 @@
},
"devDependencies": {
"@gib/prettier-config": "workspace:",
"@turbo/gen": "^2.7.4",
"baseline-browser-mapping": "^2.9.14",
"dotenv-cli": "^10.0.0",
"@turbo/gen": "^2.8.20",
"baseline-browser-mapping": "^2.10.8",
"dotenv-cli": "11.0.0",
"prettier": "catalog:",
"turbo": "^2.7.4",
"turbo": "^2.8.20",
"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

View File

@@ -23,14 +23,6 @@ import type {
FunctionReference,
} 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<{
auth: typeof auth;
crons: typeof crons;
@@ -41,14 +33,30 @@ declare const fullApi: ApiFromModules<{
http: typeof http;
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<
typeof fullApiWithMounts,
typeof fullApi,
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<
typeof fullApiWithMounts,
typeof fullApi,
FunctionReference<any, "internal">
>;

View File

@@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
* 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).
*
* 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
* strings when type checking.

View File

@@ -10,7 +10,6 @@
import {
ActionBuilder,
AnyComponents,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
@@ -19,15 +18,9 @@ import {
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
FunctionReference,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
type GenericCtx =
| GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>;
/**
* 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.
*
* This function will be used to respond to HTTP requests received by a Convex
* deployment if the requests matches the path and method where this action
* is routed. Be sure to route your action in `convex/http.js`.
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* 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.
*/
export declare const httpAction: HttpActionBuilder;

View File

@@ -16,7 +16,6 @@ import {
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
componentsGeneric,
} from "convex/server";
/**
@@ -81,10 +80,14 @@ export const action = actionGeneric;
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
* as its second.
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* 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;

View File

@@ -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({
args: {
name: v.optional(v.string()),

View File

@@ -24,9 +24,11 @@ export const validatePassword = (password: string): boolean => {
if (
password.length < 8 ||
password.length > 100 ||
/\s/.test(password) ||
!/\d/.test(password) ||
!/[a-z]/.test(password) ||
!/[A-Z]/.test(password)
!/[A-Z]/.test(password) ||
!/[\p{P}\p{S}]/u.test(password)
) {
return false;
}

View File

@@ -8,7 +8,7 @@ export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
id: 'usesend',
type: 'email',
name: 'UseSend',
from: 'Study Buddy <admin@techtracker.gbrown.org>',
from: process.env.USESEND_FROM_EMAIL ?? 'noreply@example.com',
maxAge: 24 * 60 * 60, // 24 hours
async generateVerificationToken() {
@@ -21,13 +21,14 @@ export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
},
async sendVerificationRequest(params) {
const { identifier: to, provider, url, theme, token } = params;
//const { host } = new URL(url);
const host = 'TechTracker';
const { identifier: to, provider, url, token } = params;
// Derive a display name from the site URL, fallback to 'App'
const siteUrl = process.env.USESEND_FROM_EMAIL ?? '';
const appName = siteUrl.split('@')[1]?.split('.')[0] ?? 'App';
const useSend = new UseSend(
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
@@ -38,8 +39,8 @@ export default function UseSendProvider(config: EmailUserConfig): EmailConfig {
from: provider.from!,
to: [to],
subject: isPasswordReset
? `Reset your password - ${host}`
: `Sign in to ${host}`,
? `Reset your password - ${appName}`
: `Sign in to ${appName}`,
text: isPasswordReset
? `Your password reset code is ${token}`
: `Your sign in code is ${token}`,

View File

@@ -3,22 +3,12 @@ import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
const applicationTables = {
// Users contains name image & email.
// If you would like to save any other information,
// I would recommend including this profiles table
// where you can include settings & anything else you would like tied to the user.
profiles: defineTable({
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.
/*
* Below is the users table definition from authTables
* You can add additional fields here. You can also remove
* the users table here & create a 'profiles' table if you
* prefer to keep auth data separate from application data.
*/
users: defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
@@ -27,9 +17,18 @@ export default defineSchema({
phone: v.optional(v.string()),
phoneVerificationTime: v.optional(v.number()),
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('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,
});

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

View File

@@ -4,32 +4,8 @@
"type": "module",
"exports": {
".": "./src/index.tsx",
"./avatar": "./src/avatar.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"
"./hooks": "./src/index.tsx",
"./hooks/*": "./src/hooks/*"
},
"license": "MIT",
"scripts": {
@@ -37,9 +13,10 @@
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"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": {
"@base-ui/react": "^1.3.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -55,12 +32,18 @@
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.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",
"lucide-react": "^0.542.0",
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react-day-picker": "^9.14.0",
"react-hook-form": "^7.65.0",
"react-image-crop": "^11.0.10",
"react-resizable-panels": "^4",
"recharts": "^3.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2"

View 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 };

View 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
View 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 };

View 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 };

View File

@@ -1,53 +1,97 @@
'use client';
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,
size = 'default',
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: 'default' | 'sm' | 'lg';
}) => (
<AvatarPrimitive.Root
data-slot='avatar'
data-size={size}
className={cn(
'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,
)}
{...props}
/>
);
const AvatarImage = ({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<typeof AvatarPrimitive.Image>) => (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
);
function AvatarImage({
const AvatarFallback = ({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) => (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
className,
)}
{...props}
/>
);
function 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<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
);
}
}: 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 };
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
};

49
packages/ui/src/badge.tsx Normal file
View 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 };

View 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,
};

View 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,
};

View File

@@ -1,32 +1,38 @@
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import type * as React from 'react';
import { cva } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from '@gib/ui';
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: {
variant: {
default:
'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',
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
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:
'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:
'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',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
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",
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: {
@@ -36,25 +42,27 @@ const buttonVariants = cva(
},
);
function Button({
const Button = ({
className,
variant,
size,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
}) => {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot='button'
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
};
export { Button, buttonVariants };

View 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 };

View File

@@ -1,85 +1,85 @@
import * as React from 'react';
import type * as React from 'react';
import { cn } from '@gib/ui';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card'
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
const Card = ({
className,
size = 'default',
...props
}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) => (
<div
data-slot='card'
data-size={size}
className={cn(
'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,
)}
{...props}
/>
);
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-header'
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
);
}
const CardHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot='card-header'
className={cn(
'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,
)}
{...props}
/>
);
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-title'
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
const CardTitle = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot='card-title'
className={cn(
'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
className,
)}
{...props}
/>
);
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
const CardDescription = ({
className,
...props
}: React.ComponentProps<'div'>) => (
<div
data-slot='card-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-action'
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
const CardAction = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot='card-action'
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-content'
className={cn('px-6', className)}
{...props}
/>
);
}
const CardContent = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot='card-content'
className={cn('px-4 group-data-[size=sm]/card:px-3', className)}
{...props}
/>
);
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
const CardFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot='card-footer'
className={cn(
'bg-muted/50 flex items-center rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3',
className,
)}
{...props}
/>
);
export {
Card,

View 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
View 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,
};

View File

@@ -1,32 +1,30 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import type * as React from 'react';
import { CheckIcon } from 'lucide-react';
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
import { cn } from '@gib/ui';
function Checkbox({
const Checkbox = ({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot='checkbox'
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',
className,
)}
{...props}
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) => (
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'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,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='grid place-content-center text-current transition-none [&>svg]:size-3.5'
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none'
>
<CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
export { Checkbox };

View 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 };

View 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
View 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,
};

View 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
View 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,
};

View File

@@ -1,125 +1,109 @@
'use client';
import * as React from 'react';
import type * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@gib/ui';
function Drawer({
const Drawer = ({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot='drawer' {...props} />;
}
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root data-slot='drawer' {...props} />
);
function DrawerTrigger({
const DrawerTrigger = ({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot='drawer-trigger' {...props} />;
}
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) => (
<DrawerPrimitive.Trigger data-slot='drawer-trigger' {...props} />
);
function DrawerPortal({
const DrawerPortal = ({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot='drawer-portal' {...props} />;
}
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) => (
<DrawerPrimitive.Portal data-slot='drawer-portal' {...props} />
);
function DrawerClose({
const DrawerClose = ({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot='drawer-close' {...props} />;
}
}: React.ComponentProps<typeof DrawerPrimitive.Close>) => (
<DrawerPrimitive.Close data-slot='drawer-close' {...props} />
);
function DrawerOverlay({
const DrawerOverlay = ({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot='drawer-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) => (
<DrawerPrimitive.Overlay
data-slot='drawer-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 supports-backdrop-filter:backdrop-blur-xs',
className,
)}
{...props}
/>
);
function DrawerContent({
const DrawerContent = ({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot='drawer-portal'>
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot='drawer-content'
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className,
)}
{...props}
>
<div className='bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block' />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='drawer-header'
}: React.ComponentProps<typeof DrawerPrimitive.Content>) => (
<DrawerPortal data-slot='drawer-portal'>
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot='drawer-content'
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',
'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',
className,
)}
{...props}
/>
);
}
>
<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}
</DrawerPrimitive.Content>
</DrawerPortal>
);
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='drawer-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
const DrawerHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot='drawer-header'
className={cn(
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left',
className,
)}
{...props}
/>
);
function DrawerTitle({
const DrawerFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot='drawer-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
const DrawerTitle = ({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot='drawer-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
}: React.ComponentProps<typeof DrawerPrimitive.Title>) => (
<DrawerPrimitive.Title
data-slot='drawer-title'
className={cn('text-foreground text-base font-medium', className)}
{...props}
/>
);
function DrawerDescription({
const DrawerDescription = ({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot='drawer-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
}: React.ComponentProps<typeof DrawerPrimitive.Description>) => (
<DrawerPrimitive.Description
data-slot='drawer-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
export {
Drawer,

View File

@@ -1,65 +1,56 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import type * as React from 'react';
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { cn } from '@gib/ui';
function DropdownMenu({
const DropdownMenu = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) => (
<DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />
);
function DropdownMenuPortal({
const DropdownMenuPortal = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
);
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) => (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
);
function DropdownMenuTrigger({
const DropdownMenuTrigger = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
);
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) => (
<DropdownMenuPrimitive.Trigger data-slot='dropdown-menu-trigger' {...props} />
);
function DropdownMenuContent({
const DropdownMenuContent = ({
className,
align = 'start',
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
align={align}
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-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,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
function DropdownMenuGroup({
const DropdownMenuGroup = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
);
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) => (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
);
function DropdownMenuItem({
const DropdownMenuItem = ({
className,
inset,
variant = 'default',
@@ -67,176 +58,172 @@ function DropdownMenuItem({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
}) => (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive 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,
)}
{...props}
/>
);
function DropdownMenuCheckboxItem({
const DropdownMenuCheckboxItem = ({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean;
}) => (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
data-inset={inset}
className={cn(
"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,
)}
checked={checked}
{...props}
>
<span
className='pointer-events-none absolute right-2 flex items-center justify-center'
data-slot='dropdown-menu-checkbox-item-indicator'
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
function DropdownMenuRadioGroup({
const DropdownMenuRadioGroup = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) => (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
function DropdownMenuRadioItem({
const DropdownMenuRadioItem = ({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean;
}) => (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
data-inset={inset}
className={cn(
"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,
)}
{...props}
>
<span
className='pointer-events-none absolute right-2 flex items-center justify-center'
data-slot='dropdown-menu-radio-item-indicator'
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
function DropdownMenuLabel({
const DropdownMenuLabel = ({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
);
}
}) => (
<DropdownMenuPrimitive.Label
data-slot='dropdown-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}
/>
);
function DropdownMenuSeparator({
const DropdownMenuSeparator = ({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) => (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
function DropdownMenuShortcut({
const DropdownMenuShortcut = ({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<'span'>) => (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
function DropdownMenuSub({
const DropdownMenuSub = ({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) => (
<DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />
);
function DropdownMenuSubTrigger({
const DropdownMenuSubTrigger = ({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto size-4' />
</DropdownMenuPrimitive.SubTrigger>
);
}
}) => (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
className={cn(
"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,
)}
{...props}
>
{children}
<ChevronRightIcon className='cn-rtl-flip ml-auto' />
</DropdownMenuPrimitive.SubTrigger>
);
function DropdownMenuSubContent({
const DropdownMenuSubContent = ({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) => (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-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 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,
)}
{...props}
/>
);
export {
DropdownMenu,

93
packages/ui/src/empty.tsx Normal file
View 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,
};

View File

@@ -6,58 +6,52 @@ import { cva } from 'class-variance-authority';
import { cn, Label, Separator } from '@gib/ui';
export function FieldSet({
export const FieldSet = ({
className,
...props
}: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot='field-set'
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<'fieldset'>) => (
<fieldset
data-slot='field-set'
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
);
export function FieldLegend({
export const FieldLegend = ({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot='field-legend'
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) => (
<legend
data-slot='field-legend'
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
);
export function FieldGroup({
export const FieldGroup = ({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-group'
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<'div'>) => (
<div
data-slot='field-group'
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className,
)}
{...props}
/>
);
const fieldVariants = cva(
'group/field data-[invalid=true]:text-destructive flex w-full gap-3',
@@ -83,128 +77,116 @@ const fieldVariants = cva(
},
);
export function Field({
export const Field = ({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role='group'
data-slot='field'
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) => (
<div
role='group'
data-slot='field'
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
export function FieldContent({
export const FieldContent = ({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-content'
className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<'div'>) => (
<div
data-slot='field-content'
className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
);
export function FieldLabel({
export const FieldLabel = ({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot='field-label'
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<typeof Label>) => (
<Label
data-slot='field-label'
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
);
export function FieldTitle({
export const FieldTitle = ({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-label'
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<'div'>) => (
<div
data-slot='field-label'
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
);
export function FieldDescription({
export const FieldDescription = ({
className,
...props
}: React.ComponentProps<'p'>) {
return (
<p
data-slot='field-description'
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<'p'>) => (
<p
data-slot='field-description'
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
export function FieldSeparator({
export const FieldSeparator = ({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot='field-separator'
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className='absolute inset-0 top-1/2' />
{children && (
<span
className='bg-background text-muted-foreground relative mx-auto block w-fit px-2'
data-slot='field-separator-content'
>
{children}
</span>
)}
</div>
);
}
}) => (
<div
data-slot='field-separator'
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className='absolute inset-0 top-1/2' />
{children && (
<span
className='bg-background text-muted-foreground relative mx-auto block w-fit px-2'
data-slot='field-separator-content'
>
{children}
</span>
)}
</div>
);
export function FieldError({
export const FieldError = ({
className,
children,
errors: maybeErrors,
...props
}: React.ComponentProps<'div'> & {
errors?: ({ message?: string } | undefined)[];
}) {
}) => {
const content = useMemo(() => {
if (children) {
return children;
@@ -244,4 +226,4 @@ export function FieldError({
{content}
</div>
);
}
};

View File

@@ -15,12 +15,12 @@ import { cn, Label } from '@gib/ui';
const Form = FormProvider;
type FormFieldContextValue<
interface FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
> {
name: TName;
};
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
@@ -62,15 +62,15 @@ const useFormField = () => {
};
};
type FormItemContextValue = {
interface FormItemContextValue {
id: string;
};
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const FormItem = ({ className, ...props }: React.ComponentProps<'div'>) => {
const id = React.useId();
return (
@@ -82,12 +82,12 @@ function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
/>
</FormItemContext.Provider>
);
}
};
function FormLabel({
const FormLabel = ({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
}: React.ComponentProps<typeof LabelPrimitive.Root>) => {
const { error, formItemId } = useFormField();
return (
@@ -99,9 +99,9 @@ function FormLabel({
{...props}
/>
);
}
};
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const FormControl = ({ ...props }: React.ComponentProps<typeof Slot>) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
@@ -118,9 +118,12 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
{...props}
/>
);
}
};
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const FormDescription = ({
className,
...props
}: React.ComponentProps<'p'>) => {
const { formDescriptionId } = useFormField();
return (
@@ -131,9 +134,9 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
{...props}
/>
);
}
};
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const FormMessage = ({ className, ...props }: React.ComponentProps<'p'>) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
@@ -151,7 +154,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
{body}
</p>
);
}
};
export {
useFormField,

View File

@@ -0,0 +1,2 @@
export { useIsMobile } from './use-mobile';
export { useOnClickOutside } from './use-on-click-outside';

View 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;
};

View 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 };

View 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 };

View File

@@ -25,6 +25,9 @@ import { Button, cn } from '@gib/ui';
import 'react-image-crop/dist/ReactCrop.css';
// Demo
import { UploadIcon } from 'lucide-react';
const centerAspectCrop = (
mediaWidth: number,
mediaHeight: number,
@@ -150,7 +153,7 @@ export const ImageCrop = ({
useEffect(() => {
const reader = new FileReader();
reader.addEventListener('load', () =>
setImgSrc(reader.result?.toString() ?? ''),
setImgSrc(reader.result?.toString() || ''),
);
reader.readAsDataURL(file);
}, [file]);
@@ -170,7 +173,6 @@ export const ImageCrop = ({
onChange?.(pixelCrop, percentCrop);
};
// biome-ignore lint/suspicious/useAwait: "onComplete is async"
const handleComplete = async (
pixelCrop: PixelCrop,
percentCrop: PercentCrop,
@@ -224,10 +226,10 @@ export const ImageCrop = ({
);
};
export type ImageCropContentProps = {
export interface ImageCropContentProps {
style?: CSSProperties;
className?: string;
};
}
export const ImageCropContent = ({
style,
@@ -261,9 +263,11 @@ export const ImageCropContent = ({
<img
alt='crop'
className='size-full'
height={400}
onLoad={onImageLoad}
ref={imgRef}
src={imgSrc}
width={400}
/>
)}
</ReactCrop>
@@ -289,14 +293,19 @@ export const ImageCropApply = ({
if (asChild) {
return (
<Slot.Root onClick={handleClick} {...props}>
<Slot.Root onClick={handleClick} {...(props as any)}>
{children}
</Slot.Root>
);
}
return (
<Button onClick={handleClick} size='icon' variant='ghost' {...props}>
<Button
onClick={handleClick}
size='icon'
variant='ghost'
{...(props as any)}
>
{children ?? <CropIcon className='size-4' />}
</Button>
);
@@ -321,14 +330,19 @@ export const ImageCropReset = ({
if (asChild) {
return (
<Slot.Root onClick={handleClick} {...props}>
<Slot.Root onClick={handleClick} {...(props as any)}>
{children}
</Slot.Root>
);
}
return (
<Button onClick={handleClick} size='icon' variant='ghost' {...props}>
<Button
onClick={handleClick}
size='icon'
variant='ghost'
{...(props as any)}
>
{children ?? <RotateCcwIcon className='size-4' />}
</Button>
);
@@ -358,8 +372,64 @@ export const Cropper = ({
onChange={onChange}
onComplete={onComplete}
onCrop={onCrop}
{...props}
{...(props as any)}
>
<ImageCropContent className={className} style={style} />
</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>
);
};

View File

@@ -15,10 +15,56 @@ export const ccn = ({
off: string;
}) => 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 { BasedProgress } from './based-progress';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
} from './breadcrumb';
export { Button, buttonVariants } from './button';
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
} from './button-group';
export { Calendar, CalendarDayButton } from './calendar';
export {
Card,
CardHeader,
@@ -28,7 +74,87 @@ export {
CardDescription,
CardContent,
} 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 {
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 {
Drawer,
DrawerPortal,
@@ -43,12 +169,41 @@ export {
} from './drawer';
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} 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 {
useFormField,
Form,
@@ -59,6 +214,7 @@ export {
FormMessage,
FormField,
} from './form';
export { HoverCard, HoverCardTrigger, HoverCardContent } from './hover-card';
export {
type ImageCropProps,
type ImageCropApplyProps,
@@ -70,27 +226,130 @@ export {
ImageCropApply,
ImageCropContent,
ImageCropReset,
} from './shadcn-io/image-crop';
} from './image-crop';
export { Input } from './input';
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
} from './input-group';
export {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} 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 {
NativeSelect,
NativeSelectOptGroup,
NativeSelectOption,
} from './native-select';
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
} from './navigation-menu';
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from './pagination';
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
} from './popover';
export { Progress } from './progress';
export { RadioGroup, RadioGroupItem } from './radio-group';
export {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from './resizable';
export { ScrollArea, ScrollBar } from './scroll-area';
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from './select';
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 { SubmitButton } from './submit-button';
export { Switch } from './switch';
@@ -104,6 +363,22 @@ export {
TableCell,
TableCaption,
} from './table';
export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs';
export { Toaster } from './sonner';
export {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
tabsListVariants,
} from './tabs';
export { Textarea } from './textarea';
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';

View 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,
};

View File

@@ -6,43 +6,46 @@ import { MinusIcon } from 'lucide-react';
import { cn } from '@gib/ui';
function InputOTP({
const InputOTP = ({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot='input-otp'
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
}
}) => (
<OTPInput
data-slot='input-otp'
containerClassName={cn(
'cn-input-otp flex items-center has-disabled:opacity-50',
containerClassName,
)}
spellCheck={false}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-otp-group'
className={cn('flex items-center', className)}
{...props}
/>
);
}
const InputOTPGroup = ({
className,
...props
}: React.ComponentProps<'div'>) => (
<div
data-slot='input-otp-group'
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}
/>
);
function InputOTPSlot({
const InputOTPSlot = ({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number;
}) {
}) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
@@ -51,7 +54,7 @@ function InputOTPSlot({
data-slot='input-otp-slot'
data-active={isActive}
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,
)}
{...props}
@@ -64,14 +67,17 @@ function InputOTPSlot({
)}
</div>
);
}
};
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot='input-otp-separator' role='separator' {...props}>
<MinusIcon />
</div>
);
}
const InputOTPSeparator = ({ ...props }: React.ComponentProps<'div'>) => (
<div
data-slot='input-otp-separator'
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
role='separator'
{...props}
>
<MinusIcon />
</div>
);
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -1,21 +1,21 @@
import * as React from 'react';
import type * as React from 'react';
import { cn } from '@gib/ui';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
const Input = ({
className,
type,
...props
}: React.ComponentProps<'input'>) => (
<input
type={type}
data-slot='input'
className={cn(
'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',
className,
)}
{...props}
/>
);
export { Input };

181
packages/ui/src/item.tsx Normal file
View 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
View 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 };

View File

@@ -1,24 +1,21 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import type * as React from 'react';
import { Label as LabelPrimitive } from 'radix-ui';
import { cn } from '@gib/ui';
function Label({
const Label = ({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
}: React.ComponentProps<typeof LabelPrimitive.Root>) => (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
export { Label };

252
packages/ui/src/menubar.tsx Normal file
View 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,
};

View 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 };

View 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,
};

View File

@@ -5,123 +5,115 @@ import {
MoreHorizontalIcon,
} from 'lucide-react';
import type { Button } from '@gib/ui';
import { buttonVariants, cn } from '@gib/ui';
import { Button, cn } from '@gib/ui';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role='navigation'
aria-label='pagination'
data-slot='pagination'
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
}
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role='navigation'
aria-label='pagination'
data-slot='pagination'
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
function PaginationContent({
const PaginationContent = ({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='pagination-content'
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
);
}
}: React.ComponentProps<'ul'>) => (
<ul
data-slot='pagination-content'
className={cn('flex items-center gap-0.5', className)}
{...props}
/>
);
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot='pagination-item' {...props} />;
}
const PaginationItem = ({ ...props }: React.ComponentProps<'li'>) => (
<li data-slot='pagination-item' {...props} />
);
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
function PaginationLink({
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
}: PaginationLinkProps) => (
<Button
asChild
variant={isActive ? 'outline' : 'ghost'}
size={size}
className={cn(className)}
>
<a
aria-current={isActive ? 'page' : undefined}
data-slot='pagination-link'
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
/>
);
}
</Button>
);
function PaginationPrevious({
const PaginationPrevious = ({
className,
text = 'Previous',
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) => (
<PaginationLink
aria-label='Go to previous page'
size='default'
className={cn('pl-1.5!', className)}
{...props}
>
<ChevronLeftIcon data-icon='inline-start' className='cn-rtl-flip' />
<span className='hidden sm:block'>{text}</span>
</PaginationLink>
);
const PaginationNext = ({
className,
text = 'Next',
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) => (
<PaginationLink
aria-label='Go to next page'
size='default'
className={cn('pr-1.5!', className)}
{...props}
>
<span className='hidden sm:block'>{text}</span>
<ChevronRightIcon data-icon='inline-end' className='cn-rtl-flip' />
</PaginationLink>
);
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label='Go to previous page'
size='default'
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className='hidden sm:block'>Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label='Go to next page'
size='default'
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className='hidden sm:block'>Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot='pagination-ellipsis'
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className='size-4' />
<span className='sr-only'>More pages</span>
</span>
);
}
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
data-slot='pagination-ellipsis'
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className='sr-only'>More pages</span>
</span>
);
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View 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,
};

View File

@@ -1,31 +1,29 @@
'use client';
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';
function Progress({
const Progress = ({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot='progress'
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot='progress-indicator'
className='bg-primary h-full w-full flex-1 transition-all'
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
}: React.ComponentProps<typeof ProgressPrimitive.Root>) => (
<ProgressPrimitive.Root
data-slot='progress'
className={cn(
'bg-muted relative flex h-1 w-full items-center overflow-x-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot='progress-indicator'
className='bg-primary size-full flex-1 transition-all'
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
export { Progress };

View 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