changes: Next 15 with React 19 boilerplate
This commit is contained in:
21
src/ASPBaseOIDC.Web.Ui/LICENSE
Normal file
21
src/ASPBaseOIDC.Web.Ui/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Kiranism
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
115
src/ASPBaseOIDC.Web.Ui/README.md
Normal file
115
src/ASPBaseOIDC.Web.Ui/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/9113740/201498864-2a900c64-d88f-4ed4-b5cf-770bcb57e1f5.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/9113740/201498152-b171abb8-9225-487a-821c-6ff49ee48579.png">
|
||||
</picture>
|
||||
|
||||
<div align="center"><strong>Next.js Admin Dashboard Starter Template With Shadcn-ui</strong></div>
|
||||
<div align="center">Built with the Next.js 15 App Router</div>
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://dub.sh/shadcn-dashboard">View Demo</a>
|
||||
<span>
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
|
||||
This is a starter template using the following stack:
|
||||
|
||||
- Framework - [Next.js 15](https://nextjs.org/13)
|
||||
- Language - [TypeScript](https://www.typescriptlang.org)
|
||||
- Auth - [Clerk](https://go.clerk.com/ILdYhn7)
|
||||
- Error tracking - [<picture><img alt="Sentry" src="public/assets/sentry.svg">
|
||||
</picture>](https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy26q2-nextjs&utm_content=github-banner-project-tryfree)
|
||||
- Styling - [Tailwind CSS v4](https://tailwindcss.com)
|
||||
- Components - [Shadcn-ui](https://ui.shadcn.com)
|
||||
- Schema Validations - [Zod](https://zod.dev)
|
||||
- State Management - [Zustand](https://zustand-demo.pmnd.rs)
|
||||
- Search params state manager - [Nuqs](https://nuqs.47ng.com/)
|
||||
- Tables - [Tanstack Data Tables](https://ui.shadcn.com/docs/components/data-table) • [Dice table](https://www.diceui.com/docs/components/data-table)
|
||||
- Forms - [React Hook Form](https://ui.shadcn.com/docs/components/form)
|
||||
- Command+k interface - [kbar](https://kbar.vercel.app/)
|
||||
- Linting - [ESLint](https://eslint.org)
|
||||
- Pre-commit Hooks - [Husky](https://typicode.github.io/husky/)
|
||||
- Formatting - [Prettier](https://prettier.io)
|
||||
|
||||
_If you are looking for a Tanstack start dashboard template, here is the [repo](https://git.new/tanstack-start-dashboard)._
|
||||
|
||||
## Pages
|
||||
|
||||
| Pages | Specifications |
|
||||
| :------------------------------------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Signup / Signin](https://go.clerk.com/ILdYhn7) | Authentication with **Clerk** provides secure authentication and user management with multiple sign-in options including passwordless authentication, social logins, and enterprise SSO - all designed to enhance security while delivering a seamless user experience. |
|
||||
| [Dashboard (Overview)](https://shadcn-dashboard.kiranism.dev/dashboard) | Cards with Recharts graphs for analytics. Parallel routes in the overview sections feature independent loading, error handling, and isolated component rendering. |
|
||||
| [Product](https://shadcn-dashboard.kiranism.dev/dashboard/product) | Tanstack tables with server side searching, filter, pagination by Nuqs which is a Type-safe search params state manager in nextjs |
|
||||
| [Product/new](https://shadcn-dashboard.kiranism.dev/dashboard/product/new) | A Product Form with shadcn form (react-hook-form + zod). |
|
||||
| [Profile](https://shadcn-dashboard.kiranism.dev/dashboard/profile) | Clerk's full-featured account management UI that allows users to manage their profile and security settings |
|
||||
| [Kanban Board](https://shadcn-dashboard.kiranism.dev/dashboard/kanban) | A Drag n Drop task management board with dnd-kit and zustand to persist state locally. |
|
||||
| [Not Found](https://shadcn-dashboard.kiranism.dev/dashboard/notfound) | Not Found Page Added in the root level |
|
||||
| [Global Error](https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy26q2-nextjs&utm_content=github-banner-project-tryfree) | A centralized error page that captures and displays errors across the application. Integrated with **Sentry** to log errors, provide detailed reports, and enable replay functionality for better debugging. |
|
||||
|
||||
## Feature based organization
|
||||
|
||||
```plaintext
|
||||
src/
|
||||
├── app/ # Next.js App Router directory
|
||||
│ ├── (auth)/ # Auth route group
|
||||
│ │ ├── (signin)/
|
||||
│ ├── (dashboard)/ # Dashboard route group
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── loading.tsx
|
||||
│ │ └── page.tsx
|
||||
│ └── api/ # API routes
|
||||
│
|
||||
├── components/ # Shared components
|
||||
│ ├── ui/ # UI components (buttons, inputs, etc.)
|
||||
│ └── layout/ # Layout components (header, sidebar, etc.)
|
||||
│
|
||||
├── features/ # Feature-based modules
|
||||
│ ├── feature/
|
||||
│ │ ├── components/ # Feature-specific components
|
||||
│ │ ├── actions/ # Server actions
|
||||
│ │ ├── schemas/ # Form validation schemas
|
||||
│ │ └── utils/ # Feature-specific utilities
|
||||
│ │
|
||||
├── lib/ # Core utilities and configurations
|
||||
│ ├── auth/ # Auth configuration
|
||||
│ ├── db/ # Database utilities
|
||||
│ └── utils/ # Shared utilities
|
||||
│
|
||||
├── hooks/ # Custom hooks
|
||||
│ └── use-debounce.ts
|
||||
│
|
||||
├── stores/ # Zustand stores
|
||||
│ └── dashboard-store.ts
|
||||
│
|
||||
└── types/ # TypeScript types
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
> [!NOTE]
|
||||
> We are using **Next 15** with **React 19**, follow these steps:
|
||||
|
||||
Clone the repo:
|
||||
|
||||
```
|
||||
git clone https://github.com/Kiranism/next-shadcn-dashboard-starter.git
|
||||
```
|
||||
|
||||
- `pnpm install` ( we have legacy-peer-deps=true added in the .npmrc)
|
||||
- Create a `.env.local` file by copying the example environment file:
|
||||
`cp env.example.txt .env.local`
|
||||
- Add the required environment variables to the `.env.local` file.
|
||||
- `pnpm run dev`
|
||||
|
||||
##### Environment Configuration Setup
|
||||
|
||||
To configure the environment for this project, refer to the `env.example.txt` file. This file contains the necessary environment variables required for authentication and error tracking.
|
||||
|
||||
You should now be able to access the application at http://localhost:3000.
|
||||
|
||||
> [!WARNING]
|
||||
> After cloning or forking the repository, be cautious when pulling or syncing with the latest changes, as this may result in breaking conflicts.
|
||||
|
||||
Cheers! 🥂
|
||||
16
src/ASPBaseOIDC.Web.Ui/components.json
Normal file
16
src/ASPBaseOIDC.Web.Ui/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
65
src/ASPBaseOIDC.Web.Ui/env.example.txt
Normal file
65
src/ASPBaseOIDC.Web.Ui/env.example.txt
Normal file
@@ -0,0 +1,65 @@
|
||||
# =================================================================
|
||||
# Authentication Configuration (Clerk)
|
||||
# =================================================================
|
||||
# IMPORTANT: This template supports Clerk's keyless mode!
|
||||
# You can start using the app immediately without any configuration.
|
||||
# When you're ready to claim your application, simply click the Clerk
|
||||
# popup at the bottom of the screen to get your API keys.
|
||||
|
||||
# Required: Clerk API Keys (Leave empty for keyless mode)
|
||||
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
|
||||
|
||||
# Authentication Redirect URLs
|
||||
# These control where users are directed after authentication actions
|
||||
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/auth/sign-in"
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL="/auth/sign-up"
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL="/dashboard/overview"
|
||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL="/dashboard/overview"
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Error Tracking Configuration (Sentry)
|
||||
# =================================================================
|
||||
# To set up Sentry error tracking:
|
||||
# 1. Create an account at https://sentry.io
|
||||
# 2. Create a new project for Next.js
|
||||
# 3. Follow the setup instructions below
|
||||
|
||||
# Step 1: Sentry DSN (Required)
|
||||
# Found at: Settings > Projects > [Your Project] > Client Keys (DSN)
|
||||
|
||||
NEXT_PUBLIC_SENTRY_DSN= #Example: https://****@****.ingest.sentry.io/****
|
||||
|
||||
|
||||
# Step 2: Organization & Project Details
|
||||
# Found at: Settings > Organization > General Settings
|
||||
|
||||
NEXT_PUBLIC_SENTRY_ORG= # Example: acme-corp
|
||||
NEXT_PUBLIC_SENTRY_PROJECT= # Example: nextjs-dashboard
|
||||
|
||||
|
||||
# Step 3: Sentry Auth Token
|
||||
|
||||
# Sentry can automatically provide readable stack traces for errors using source maps, requiring a Sentry auth token.
|
||||
# More info: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#step-4-add-readable-stack-traces-with-source-maps-optional
|
||||
|
||||
SENTRY_AUTH_TOKEN= #Example: sntrys_************************************
|
||||
|
||||
|
||||
# Step 4: Environment Control (Optional)
|
||||
# Set to 'true' to disable Sentry in development
|
||||
|
||||
NEXT_PUBLIC_SENTRY_DISABLED= "false"
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Important Notes:
|
||||
# =================================================================
|
||||
# 1. Rename this file to '.env' for local development
|
||||
# 2. Never commit the actual '.env' file to version control
|
||||
# 3. Make sure to replace all placeholder values with real ones
|
||||
# 4. Keep your secret keys private and never share them
|
||||
57
src/ASPBaseOIDC.Web.Ui/next.config.ts
Normal file
57
src/ASPBaseOIDC.Web.Ui/next.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
// Define the base Next.js configuration
|
||||
const baseConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.slingacademy.com',
|
||||
port: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
transpilePackages: ['geist']
|
||||
};
|
||||
|
||||
let configWithPlugins = baseConfig;
|
||||
|
||||
// Conditionally enable Sentry configuration
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DISABLED) {
|
||||
configWithPlugins = withSentryConfig(configWithPlugins, {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
// FIXME: Add your Sentry organization and project names
|
||||
org: process.env.NEXT_PUBLIC_SENTRY_ORG,
|
||||
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
|
||||
// Only print logs for uploading source maps in 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)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
reactComponentAnnotation: {
|
||||
enabled: true
|
||||
},
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: '/monitoring',
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Disable Sentry telemetry
|
||||
telemetry: false
|
||||
});
|
||||
}
|
||||
|
||||
const nextConfig = configWithPlugins;
|
||||
export default nextConfig;
|
||||
@@ -1,15 +1,119 @@
|
||||
{
|
||||
"name": "aspbaseoidc.web.ui",
|
||||
"name": "next-shadcn-dashboard-starter",
|
||||
"version": "1.0.0",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "Kiran",
|
||||
"url": "https://github.com/Kiranism"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "eslint src --fix && pnpm format",
|
||||
"lint:strict": "eslint --max-warnings=0 src",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier -c -w .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,tsx,ts,css,less,scss,sass}": [
|
||||
"prettier --write --no-error-on-unmatched-pattern"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^6.12.12",
|
||||
"@clerk/themes": "^2.2.26",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.6",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.6",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-menubar": "^1.1.6",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@sentry/nextjs": "^9.19.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"kbar": "^0.1.0-beta.45",
|
||||
"lucide-react": "^0.476.0",
|
||||
"match-sorter": "^8.0.0",
|
||||
"motion": "^11.17.0",
|
||||
"next": "15.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^3.7.15",
|
||||
"nuqs": "^2.4.1",
|
||||
"postcss": "8.4.49",
|
||||
"react": "19.0.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-responsive": "^10.0.0",
|
||||
"recharts": "^2.15.1",
|
||||
"sharp": "^0.33.5",
|
||||
"sonner": "^1.7.1",
|
||||
"sort-by": "^1.2.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.7.2",
|
||||
"uuid": "^11.0.3",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.8",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"eslint": "^9.37.0"
|
||||
"@faker-js/faker": "^9.3.0",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/react": "19.0.1",
|
||||
"@types/react-dom": "19.0.2",
|
||||
"@types/sort-by": "^1.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.11",
|
||||
"prettier": "3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tw-animate-css": "^1.2.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "19.0.1",
|
||||
"@types/react-dom": "19.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9104
src/ASPBaseOIDC.Web.Ui/pnpm-lock.yaml
generated
Normal file
9104
src/ASPBaseOIDC.Web.Ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
src/ASPBaseOIDC.Web.Ui/postcss.config.js
Normal file
5
src/ASPBaseOIDC.Web.Ui/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
1
src/ASPBaseOIDC.Web.Ui/public/assets/sentry.svg
Normal file
1
src/ASPBaseOIDC.Web.Ui/public/assets/sentry.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="css-lfbo6j e1igk8x04" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222 66" width="80" height="24" style="background-color: rgb(88, 70, 116);"><path d="M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z M124.32,28.28,109.56,9.22h-3.68V34.77h3.73V15.19l15.18,19.58h3.26V9.22h-3.73ZM87.15,23.54h13.23V20.22H87.14V12.53h14.93V9.21H83.34V34.77h18.92V31.45H87.14ZM71.59,20.3h0C66.44,19.06,65,18.08,65,15.7c0-2.14,1.89-3.59,4.71-3.59a12.06,12.06,0,0,1,7.07,2.55l2-2.83a14.1,14.1,0,0,0-9-3c-5.06,0-8.59,3-8.59,7.27,0,4.6,3,6.19,8.46,7.52C74.51,24.74,76,25.78,76,28.11s-2,3.77-5.09,3.77a12.34,12.34,0,0,1-8.3-3.26l-2.25,2.69a15.94,15.94,0,0,0,10.42,3.85c5.48,0,9-2.95,9-7.51C79.75,23.79,77.47,21.72,71.59,20.3ZM195.7,9.22l-7.69,12-7.64-12h-4.46L186,24.67V34.78h3.84V24.55L200,9.22Zm-64.63,3.46h8.37v22.1h3.84V12.68h8.37V9.22H131.08ZM169.41,24.8c3.86-1.07,6-3.77,6-7.63,0-4.91-3.59-8-9.38-8H154.67V34.76h3.8V25.58h6.45l6.48,9.2h4.44l-7-9.82Zm-10.95-2.5V12.6h7.17c3.74,0,5.88,1.77,5.88,4.84s-2.29,4.86-5.84,4.86Z" transform="translate(11, 11)" fill="#ffffff"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/ASPBaseOIDC.Web.Ui/public/next.svg
Normal file
1
src/ASPBaseOIDC.Web.Ui/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/ASPBaseOIDC.Web.Ui/public/vercel.svg
Normal file
1
src/ASPBaseOIDC.Web.Ui/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,28 @@
|
||||
import { Metadata } from 'next';
|
||||
import SignInViewPage from '@/features/auth/components/sign-in-view';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Authentication | Sign In',
|
||||
description: 'Sign In page for authentication.'
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
let stars = 3000; // Default value
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/kiranism/next-shadcn-dashboard-starter',
|
||||
{
|
||||
next: { revalidate: 86400 }
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
stars = data.stargazers_count || stars; // Update stars if API response is valid
|
||||
}
|
||||
} catch (error) {
|
||||
// Error fetching GitHub stars, using default value
|
||||
}
|
||||
return <SignInViewPage stars={stars} />;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Metadata } from 'next';
|
||||
import SignUpViewPage from '@/features/auth/components/sign-up-view';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Authentication | Sign Up',
|
||||
description: 'Sign Up page for authentication.'
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
let stars = 3000; // Default value
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/kiranism/next-shadcn-dashboard-starter',
|
||||
{
|
||||
next: { revalidate: 86400 }
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
stars = data.stargazers_count || stars; // Update stars if API response is valid
|
||||
}
|
||||
} catch (error) {
|
||||
// Error fetching GitHub stars, using default value
|
||||
}
|
||||
return <SignUpViewPage stars={stars} />;
|
||||
}
|
||||
9
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/kanban/page.tsx
Normal file
9
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/kanban/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import KanbanViewPage from '@/features/kanban/components/kanban-view-page';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard : Kanban view'
|
||||
};
|
||||
|
||||
export default function page() {
|
||||
return <KanbanViewPage />;
|
||||
}
|
||||
34
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/layout.tsx
Normal file
34
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import KBar from '@/components/kbar';
|
||||
import AppSidebar from '@/components/layout/app-sidebar';
|
||||
import Header from '@/components/layout/header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Next Shadcn Dashboard Starter',
|
||||
description: 'Basic dashboard with Next.js and Shadcn'
|
||||
};
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Persisting the sidebar state in the cookie.
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true"
|
||||
return (
|
||||
<KBar>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
{/* page main content */}
|
||||
{children}
|
||||
{/* page main content ends */}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</KBar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
|
||||
export default function AreaStatsError({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<IconAlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load area statistics: {error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <AreaGraphSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { delay } from '@/constants/mock-api';
|
||||
import { AreaGraph } from '@/features/overview/components/area-graph';
|
||||
|
||||
export default async function AreaStats() {
|
||||
await await delay(2000);
|
||||
return <AreaGraph />;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useTransition } from 'react';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
interface StatsErrorProps {
|
||||
error: Error;
|
||||
reset: () => void; // Add reset function from error boundary
|
||||
}
|
||||
export default function StatsError({ error, reset }: StatsErrorProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
// the reload fn ensures the refresh is deffered until the next render phase allowing react to handle any pending states before processing
|
||||
const reload = () => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
reset();
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Card className='border-red-500'>
|
||||
<CardHeader className='flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row'>
|
||||
<div className='flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6'>
|
||||
<Alert variant='destructive' className='border-none'>
|
||||
<IconAlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription className='mt-2'>
|
||||
Failed to load statistics: {error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='flex h-[316px] items-center justify-center p-6'>
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground mb-4 text-sm'>
|
||||
Unable to display statistics at this time
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => reload()}
|
||||
variant='outline'
|
||||
className='min-w-[120px]'
|
||||
disabled={isPending}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BarGraphSkeleton } from '@/features/overview/components/bar-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <BarGraphSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { delay } from '@/constants/mock-api';
|
||||
import { BarGraph } from '@/features/overview/components/bar-graph';
|
||||
|
||||
export default async function BarStats() {
|
||||
await await delay(1000);
|
||||
|
||||
return <BarGraph />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
|
||||
export default function PieStatsError({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<IconAlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load pie statistics: {error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PieGraphSkeleton } from '@/features/overview/components/pie-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <PieGraphSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { delay } from '@/constants/mock-api';
|
||||
import { PieGraph } from '@/features/overview/components/pie-graph';
|
||||
|
||||
export default async function Stats() {
|
||||
await delay(1000);
|
||||
return <PieGraph />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
|
||||
export default function SalesError({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<IconAlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load sales data: {error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { RecentSalesSkeleton } from '@/features/overview/components/recent-sales-skeleton';
|
||||
import React from 'react';
|
||||
|
||||
export default function Loading() {
|
||||
return <RecentSalesSkeleton />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { delay } from '@/constants/mock-api';
|
||||
import { RecentSales } from '@/features/overview/components/recent-sales';
|
||||
|
||||
export default async function Sales() {
|
||||
await delay(3000);
|
||||
return <RecentSales />;
|
||||
}
|
||||
16
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/overview/error.tsx
Normal file
16
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/overview/error.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
|
||||
export default function OverviewError({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<IconAlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load statistics: {error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
137
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/overview/layout.tsx
Normal file
137
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/overview/layout.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardAction,
|
||||
CardFooter
|
||||
} from '@/components/ui/card';
|
||||
import { IconTrendingDown, IconTrendingUp } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
export default function OverViewLayout({
|
||||
sales,
|
||||
pie_stats,
|
||||
bar_stats,
|
||||
area_stats
|
||||
}: {
|
||||
sales: React.ReactNode;
|
||||
pie_stats: React.ReactNode;
|
||||
bar_stats: React.ReactNode;
|
||||
area_stats: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-2'>
|
||||
<div className='flex items-center justify-between space-y-2'>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>
|
||||
Hi, Welcome back 👋
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className='*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs md:grid-cols-2 lg:grid-cols-4'>
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardDescription>Total Revenue</CardDescription>
|
||||
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
|
||||
$1,250.00
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant='outline'>
|
||||
<IconTrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
|
||||
<div className='line-clamp-1 flex gap-2 font-medium'>
|
||||
Trending up this month <IconTrendingUp className='size-4' />
|
||||
</div>
|
||||
<div className='text-muted-foreground'>
|
||||
Visitors for the last 6 months
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardDescription>New Customers</CardDescription>
|
||||
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
|
||||
1,234
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant='outline'>
|
||||
<IconTrendingDown />
|
||||
-20%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
|
||||
<div className='line-clamp-1 flex gap-2 font-medium'>
|
||||
Down 20% this period <IconTrendingDown className='size-4' />
|
||||
</div>
|
||||
<div className='text-muted-foreground'>
|
||||
Acquisition needs attention
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardDescription>Active Accounts</CardDescription>
|
||||
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
|
||||
45,678
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant='outline'>
|
||||
<IconTrendingUp />
|
||||
+12.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
|
||||
<div className='line-clamp-1 flex gap-2 font-medium'>
|
||||
Strong user retention <IconTrendingUp className='size-4' />
|
||||
</div>
|
||||
<div className='text-muted-foreground'>
|
||||
Engagement exceed targets
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardDescription>Growth Rate</CardDescription>
|
||||
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
|
||||
4.5%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant='outline'>
|
||||
<IconTrendingUp />
|
||||
+4.5%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
|
||||
<div className='line-clamp-1 flex gap-2 font-medium'>
|
||||
Steady performance increase{' '}
|
||||
<IconTrendingUp className='size-4' />
|
||||
</div>
|
||||
<div className='text-muted-foreground'>
|
||||
Meets growth projections
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
|
||||
<div className='col-span-4'>{bar_stats}</div>
|
||||
<div className='col-span-4 md:col-span-3'>
|
||||
{/* sales arallel routes */}
|
||||
{sales}
|
||||
</div>
|
||||
<div className='col-span-4'>{area_stats}</div>
|
||||
<div className='col-span-4 md:col-span-3'>{pie_stats}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
12
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/page.tsx
Normal file
12
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Dashboard() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return redirect('/auth/sign-in');
|
||||
} else {
|
||||
redirect('/dashboard/overview');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import FormCardSkeleton from '@/components/form-card-skeleton';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Suspense } from 'react';
|
||||
import ProductViewPage from '@/features/products/components/product-view-page';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard : Product View'
|
||||
};
|
||||
|
||||
type PageProps = { params: Promise<{ productId: string }> };
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
return (
|
||||
<PageContainer scrollable>
|
||||
<div className='flex-1 space-y-4'>
|
||||
<Suspense fallback={<FormCardSkeleton />}>
|
||||
<ProductViewPage productId={params.productId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
57
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/product/page.tsx
Normal file
57
src/ASPBaseOIDC.Web.Ui/src/app/dashboard/product/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { DataTableSkeleton } from '@/components/ui/table/data-table-skeleton';
|
||||
import ProductListingPage from '@/features/products/components/product-listing';
|
||||
import { searchParamsCache, serialize } from '@/lib/searchparams';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { SearchParams } from 'nuqs/server';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard: Products'
|
||||
};
|
||||
|
||||
type pageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: pageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
// Allow nested RSCs to access the search params (in a type-safe way)
|
||||
searchParamsCache.parse(searchParams);
|
||||
|
||||
// This key is used for invoke suspense if any of the search params changed (used for filters).
|
||||
// const key = serialize({ ...searchParams });
|
||||
|
||||
return (
|
||||
<PageContainer scrollable={false}>
|
||||
<div className='flex flex-1 flex-col space-y-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading
|
||||
title='Products'
|
||||
description='Manage products (Server side table functionalities.)'
|
||||
/>
|
||||
<Link
|
||||
href='/dashboard/product/new'
|
||||
className={cn(buttonVariants(), 'text-xs md:text-sm')}
|
||||
>
|
||||
<IconPlus className='mr-2 h-4 w-4' /> Add New
|
||||
</Link>
|
||||
</div>
|
||||
<Separator />
|
||||
<Suspense
|
||||
// key={key}
|
||||
fallback={
|
||||
<DataTableSkeleton columnCount={5} rowCount={8} filterCount={2} />
|
||||
}
|
||||
>
|
||||
<ProductListingPage />
|
||||
</Suspense>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import ProfileViewPage from '@/features/profile/components/profile-view-page';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard : Profile'
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
return <ProfileViewPage />;
|
||||
}
|
||||
BIN
src/ASPBaseOIDC.Web.Ui/src/app/favicon.ico
Normal file
BIN
src/ASPBaseOIDC.Web.Ui/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
27
src/ASPBaseOIDC.Web.Ui/src/app/global-error.tsx
Normal file
27
src/ASPBaseOIDC.Web.Ui/src/app/global-error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import NextError from 'next/error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function GlobalError({
|
||||
error
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
158
src/ASPBaseOIDC.Web.Ui/src/app/globals.css
Normal file
158
src/ASPBaseOIDC.Web.Ui/src/app/globals.css
Normal file
@@ -0,0 +1,158 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@import './theme.css';
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.269 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* View Transition Wave Effect */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
/* Ensure the outgoing view (old theme) is beneath */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
/* Ensure the incoming view (new theme) is always on top */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from {
|
||||
/* Use CSS variables for the origin, defaulting to center if not set */
|
||||
clip-path: circle(0% at var(--x, 50%) var(--y, 50%));
|
||||
opacity: 0.7;
|
||||
}
|
||||
to {
|
||||
/* Use CSS variables for the origin, defaulting to center if not set */
|
||||
clip-path: circle(150% at var(--x, 50%) var(--y, 50%));
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
/* Apply the reveal animation */
|
||||
animation: reveal 0.4s ease-in-out forwards;
|
||||
}
|
||||
77
src/ASPBaseOIDC.Web.Ui/src/app/layout.tsx
Normal file
77
src/ASPBaseOIDC.Web.Ui/src/app/layout.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Providers from '@/components/layout/providers';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { fontVariables } from '@/lib/font';
|
||||
import ThemeProvider from '@/components/layout/ThemeToggle/theme-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import './globals.css';
|
||||
import './theme.css';
|
||||
|
||||
const META_THEME_COLORS = {
|
||||
light: '#ffffff',
|
||||
dark: '#09090b'
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Next Shadcn',
|
||||
description: 'Basic dashboard with Next.js and Shadcn'
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: META_THEME_COLORS.light
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const activeThemeValue = cookieStore.get('active_theme')?.value;
|
||||
const isScaled = activeThemeValue?.endsWith('-scaled');
|
||||
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
|
||||
}
|
||||
} catch (_) {}
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
'bg-background overflow-hidden overscroll-none font-sans antialiased',
|
||||
activeThemeValue ? `theme-${activeThemeValue}` : '',
|
||||
isScaled ? 'theme-scaled' : '',
|
||||
fontVariables
|
||||
)}
|
||||
>
|
||||
<NextTopLoader color='var(--primary)' showSpinner={false} />
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
>
|
||||
<Providers activeThemeValue={activeThemeValue as string}>
|
||||
<Toaster />
|
||||
{children}
|
||||
</Providers>
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
36
src/ASPBaseOIDC.Web.Ui/src/app/not-found.tsx
Normal file
36
src/ASPBaseOIDC.Web.Ui/src/app/not-found.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className='absolute top-1/2 left-1/2 mb-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center text-center'>
|
||||
<span className='from-foreground bg-linear-to-b to-transparent bg-clip-text text-[10rem] leading-none font-extrabold text-transparent'>
|
||||
404
|
||||
</span>
|
||||
<h2 className='font-heading my-2 text-2xl font-bold'>
|
||||
Something's missing
|
||||
</h2>
|
||||
<p>
|
||||
Sorry, the page you are looking for doesn't exist or has been
|
||||
moved.
|
||||
</p>
|
||||
<div className='mt-8 flex justify-center gap-2'>
|
||||
<Button onClick={() => router.back()} variant='default' size='lg'>
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
variant='ghost'
|
||||
size='lg'
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/ASPBaseOIDC.Web.Ui/src/app/page.tsx
Normal file
12
src/ASPBaseOIDC.Web.Ui/src/app/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Page() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return redirect('/auth/sign-in');
|
||||
} else {
|
||||
redirect('/dashboard/overview');
|
||||
}
|
||||
}
|
||||
105
src/ASPBaseOIDC.Web.Ui/src/app/theme.css
Normal file
105
src/ASPBaseOIDC.Web.Ui/src/app/theme.css
Normal file
@@ -0,0 +1,105 @@
|
||||
body {
|
||||
@apply overscroll-none bg-transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: var(--font-inter);
|
||||
--header-height: calc(var(--spacing) * 12 + 1px);
|
||||
}
|
||||
|
||||
.theme-scaled {
|
||||
@media (min-width: 1024px) {
|
||||
--radius: 0.6rem;
|
||||
--text-lg: 1.05rem;
|
||||
--text-base: 0.85rem;
|
||||
--text-sm: 0.8rem;
|
||||
--spacing: 0.222222rem;
|
||||
}
|
||||
|
||||
[data-slot='card'] {
|
||||
--spacing: 0.16rem;
|
||||
}
|
||||
|
||||
[data-slot='select-trigger'],
|
||||
[data-slot='toggle-group-item'] {
|
||||
--spacing: 0.222222rem;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-default,
|
||||
.theme-default-scaled {
|
||||
--primary: var(--color-neutral-600);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-neutral-500);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-blue,
|
||||
.theme-blue-scaled {
|
||||
--primary: var(--color-blue-600);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-blue-500);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-green,
|
||||
.theme-green-scaled {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-amber,
|
||||
.theme-amber-scaled {
|
||||
--primary: var(--color-amber-600);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-amber-500);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-mono,
|
||||
.theme-mono-scaled {
|
||||
--font-sans: var(--font-mono);
|
||||
--primary: var(--color-neutral-600);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-neutral-500);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
}
|
||||
|
||||
.rounded-xs,
|
||||
.rounded-sm,
|
||||
.rounded-md,
|
||||
.rounded-lg,
|
||||
.rounded-xl {
|
||||
@apply !rounded-none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.shadow-xs,
|
||||
.shadow-sm,
|
||||
.shadow-md,
|
||||
.shadow-lg,
|
||||
.shadow-xl {
|
||||
@apply !shadow-none;
|
||||
}
|
||||
|
||||
[data-slot='toggle-group'],
|
||||
[data-slot='toggle-group-item'] {
|
||||
@apply !rounded-none !shadow-none;
|
||||
}
|
||||
}
|
||||
67
src/ASPBaseOIDC.Web.Ui/src/components/active-theme.tsx
Normal file
67
src/ASPBaseOIDC.Web.Ui/src/components/active-theme.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
const COOKIE_NAME = 'active_theme';
|
||||
const DEFAULT_THEME = 'default';
|
||||
|
||||
function setThemeCookie(theme: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
document.cookie = `${COOKIE_NAME}=${theme}; path=/; max-age=31536000; SameSite=Lax; ${window.location.protocol === 'https:' ? 'Secure;' : ''}`;
|
||||
}
|
||||
|
||||
type ThemeContextType = {
|
||||
activeTheme: string;
|
||||
setActiveTheme: (theme: string) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ActiveThemeProvider({
|
||||
children,
|
||||
initialTheme
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialTheme?: string;
|
||||
}) {
|
||||
const [activeTheme, setActiveTheme] = useState<string>(
|
||||
() => initialTheme || DEFAULT_THEME
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThemeCookie(activeTheme);
|
||||
|
||||
Array.from(document.body.classList)
|
||||
.filter((className) => className.startsWith('theme-'))
|
||||
.forEach((className) => {
|
||||
document.body.classList.remove(className);
|
||||
});
|
||||
document.body.classList.add(`theme-${activeTheme}`);
|
||||
if (activeTheme.endsWith('-scaled')) {
|
||||
document.body.classList.add('theme-scaled');
|
||||
}
|
||||
}, [activeTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ activeTheme, setActiveTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useThemeConfig() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useThemeConfig must be used within an ActiveThemeProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
41
src/ASPBaseOIDC.Web.Ui/src/components/breadcrumbs.tsx
Normal file
41
src/ASPBaseOIDC.Web.Ui/src/components/breadcrumbs.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { useBreadcrumbs } from '@/hooks/use-breadcrumbs';
|
||||
import { IconSlash } from '@tabler/icons-react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const items = useBreadcrumbs();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.title}>
|
||||
{index !== items.length - 1 && (
|
||||
<BreadcrumbItem className='hidden md:block'>
|
||||
<BreadcrumbLink href={item.link}>{item.title}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
{index < items.length - 1 && (
|
||||
<BreadcrumbSeparator className='hidden md:block'>
|
||||
<IconSlash />
|
||||
</BreadcrumbSeparator>
|
||||
)}
|
||||
{index === items.length - 1 && (
|
||||
<BreadcrumbPage>{item.title}</BreadcrumbPage>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
317
src/ASPBaseOIDC.Web.Ui/src/components/file-uploader.tsx
Normal file
317
src/ASPBaseOIDC.Web.Ui/src/components/file-uploader.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { IconX, IconUpload } from '@tabler/icons-react';
|
||||
import Image from 'next/image';
|
||||
import * as React from 'react';
|
||||
import Dropzone, {
|
||||
type DropzoneProps,
|
||||
type FileRejection
|
||||
} from 'react-dropzone';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useControllableState } from '@/hooks/use-controllable-state';
|
||||
import { cn, formatBytes } from '@/lib/utils';
|
||||
|
||||
export interface FileUploaderProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Value of the uploader.
|
||||
* @type File[]
|
||||
* @default undefined
|
||||
* @example value={files}
|
||||
*/
|
||||
value?: File[];
|
||||
|
||||
/**
|
||||
* Function to be called when the value changes.
|
||||
* @type React.Dispatch<React.SetStateAction<File[]>>
|
||||
* @default undefined
|
||||
* @example onValueChange={(files) => setFiles(files)}
|
||||
*/
|
||||
onValueChange?: React.Dispatch<React.SetStateAction<File[]>>;
|
||||
|
||||
/**
|
||||
* Function to be called when files are uploaded.
|
||||
* @type (files: File[]) => Promise<void>
|
||||
* @default undefined
|
||||
* @example onUpload={(files) => uploadFiles(files)}
|
||||
*/
|
||||
onUpload?: (files: File[]) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Progress of the uploaded files.
|
||||
* @type Record<string, number> | undefined
|
||||
* @default undefined
|
||||
* @example progresses={{ "file1.png": 50 }}
|
||||
*/
|
||||
progresses?: Record<string, number>;
|
||||
|
||||
/**
|
||||
* Accepted file types for the uploader.
|
||||
* @type { [key: string]: string[]}
|
||||
* @default
|
||||
* ```ts
|
||||
* { "image/*": [] }
|
||||
* ```
|
||||
* @example accept={["image/png", "image/jpeg"]}
|
||||
*/
|
||||
accept?: DropzoneProps['accept'];
|
||||
|
||||
/**
|
||||
* Maximum file size for the uploader.
|
||||
* @type number | undefined
|
||||
* @default 1024 * 1024 * 2 // 2MB
|
||||
* @example maxSize={1024 * 1024 * 2} // 2MB
|
||||
*/
|
||||
maxSize?: DropzoneProps['maxSize'];
|
||||
|
||||
/**
|
||||
* Maximum number of files for the uploader.
|
||||
* @type number | undefined
|
||||
* @default 1
|
||||
* @example maxFiles={5}
|
||||
*/
|
||||
maxFiles?: DropzoneProps['maxFiles'];
|
||||
|
||||
/**
|
||||
* Whether the uploader should accept multiple files.
|
||||
* @type boolean
|
||||
* @default false
|
||||
* @example multiple
|
||||
*/
|
||||
multiple?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the uploader is disabled.
|
||||
* @type boolean
|
||||
* @default false
|
||||
* @example disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FileUploader(props: FileUploaderProps) {
|
||||
const {
|
||||
value: valueProp,
|
||||
onValueChange,
|
||||
onUpload,
|
||||
progresses,
|
||||
accept = { 'image/*': [] },
|
||||
maxSize = 1024 * 1024 * 2,
|
||||
maxFiles = 1,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
className,
|
||||
...dropzoneProps
|
||||
} = props;
|
||||
|
||||
const [files, setFiles] = useControllableState({
|
||||
prop: valueProp,
|
||||
onChange: onValueChange
|
||||
});
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
if (!multiple && maxFiles === 1 && acceptedFiles.length > 1) {
|
||||
toast.error('Cannot upload more than 1 file at a time');
|
||||
return;
|
||||
}
|
||||
|
||||
if ((files?.length ?? 0) + acceptedFiles.length > maxFiles) {
|
||||
toast.error(`Cannot upload more than ${maxFiles} files`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = acceptedFiles.map((file) =>
|
||||
Object.assign(file, {
|
||||
preview: URL.createObjectURL(file)
|
||||
})
|
||||
);
|
||||
|
||||
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
|
||||
|
||||
setFiles(updatedFiles);
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
rejectedFiles.forEach(({ file }) => {
|
||||
toast.error(`File ${file.name} was rejected`);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
onUpload &&
|
||||
updatedFiles.length > 0 &&
|
||||
updatedFiles.length <= maxFiles
|
||||
) {
|
||||
const target =
|
||||
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
|
||||
|
||||
toast.promise(onUpload(updatedFiles), {
|
||||
loading: `Uploading ${target}...`,
|
||||
success: () => {
|
||||
setFiles([]);
|
||||
return `${target} uploaded`;
|
||||
},
|
||||
error: `Failed to upload ${target}`
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
[files, maxFiles, multiple, onUpload, setFiles]
|
||||
);
|
||||
|
||||
function onRemove(index: number) {
|
||||
if (!files) return;
|
||||
const newFiles = files.filter((_, i) => i !== index);
|
||||
setFiles(newFiles);
|
||||
onValueChange?.(newFiles);
|
||||
}
|
||||
|
||||
// Revoke preview url when component unmounts
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (!files) return;
|
||||
files.forEach((file) => {
|
||||
if (isFileWithPreview(file)) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isDisabled = disabled || (files?.length ?? 0) >= maxFiles;
|
||||
|
||||
return (
|
||||
<div className='relative flex flex-col gap-6 overflow-hidden'>
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
accept={accept}
|
||||
maxSize={maxSize}
|
||||
maxFiles={maxFiles}
|
||||
multiple={maxFiles > 1 || multiple}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'group border-muted-foreground/25 hover:bg-muted/25 relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition',
|
||||
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden',
|
||||
isDragActive && 'border-muted-foreground/50',
|
||||
isDisabled && 'pointer-events-none opacity-60',
|
||||
className
|
||||
)}
|
||||
{...dropzoneProps}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<div className='flex flex-col items-center justify-center gap-4 sm:px-5'>
|
||||
<div className='rounded-full border border-dashed p-3'>
|
||||
<IconUpload
|
||||
className='text-muted-foreground size-7'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<p className='text-muted-foreground font-medium'>
|
||||
Drop the files here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center gap-4 sm:px-5'>
|
||||
<div className='rounded-full border border-dashed p-3'>
|
||||
<IconUpload
|
||||
className='text-muted-foreground size-7'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-px'>
|
||||
<p className='text-muted-foreground font-medium'>
|
||||
Drag {`'n'`} drop files here, or click to select files
|
||||
</p>
|
||||
<p className='text-muted-foreground/70 text-sm'>
|
||||
You can upload
|
||||
{maxFiles > 1
|
||||
? ` ${maxFiles === Infinity ? 'multiple' : maxFiles}
|
||||
files (up to ${formatBytes(maxSize)} each)`
|
||||
: ` a file with ${formatBytes(maxSize)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
{files?.length ? (
|
||||
<ScrollArea className='h-fit w-full px-3'>
|
||||
<div className='max-h-48 space-y-4'>
|
||||
{files?.map((file, index) => (
|
||||
<FileCard
|
||||
key={index}
|
||||
file={file}
|
||||
onRemove={() => onRemove(index)}
|
||||
progress={progresses?.[file.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
function FileCard({ file, progress, onRemove }: FileCardProps) {
|
||||
return (
|
||||
<div className='relative flex items-center space-x-4'>
|
||||
<div className='flex flex-1 space-x-4'>
|
||||
{isFileWithPreview(file) ? (
|
||||
<Image
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
width={48}
|
||||
height={48}
|
||||
loading='lazy'
|
||||
className='aspect-square shrink-0 rounded-md object-cover'
|
||||
/>
|
||||
) : null}
|
||||
<div className='flex w-full flex-col gap-2'>
|
||||
<div className='space-y-px'>
|
||||
<p className='text-foreground/80 line-clamp-1 text-sm font-medium'>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
{progress ? <Progress value={progress} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onRemove}
|
||||
disabled={progress !== undefined && progress < 100}
|
||||
className='size-8 rounded-full'
|
||||
>
|
||||
<IconX className='text-muted-foreground' />
|
||||
<span className='sr-only'>Remove file</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isFileWithPreview(file: File): file is File & { preview: string } {
|
||||
return 'preview' in file && typeof file.preview === 'string';
|
||||
}
|
||||
52
src/ASPBaseOIDC.Web.Ui/src/components/form-card-skeleton.tsx
Normal file
52
src/ASPBaseOIDC.Web.Ui/src/components/form-card-skeleton.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader } from './ui/card';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
|
||||
export default function FormCardSkeleton() {
|
||||
return (
|
||||
<Card className='mx-auto w-full'>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-8 w-48' /> {/* Title */}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-8'>
|
||||
{/* Image upload area skeleton */}
|
||||
<div className='space-y-6'>
|
||||
<Skeleton className='h-4 w-16' /> {/* Label */}
|
||||
<Skeleton className='h-32 w-full rounded-lg' /> {/* Upload area */}
|
||||
</div>
|
||||
|
||||
{/* Grid layout for form fields */}
|
||||
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
|
||||
{/* Product Name field */}
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-24' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Category field */}
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-20' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Select */}
|
||||
</div>
|
||||
|
||||
{/* Price field */}
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-16' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-24' /> {/* Label */}
|
||||
<Skeleton className='h-32 w-full' /> {/* Textarea */}
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Skeleton className='h-10 w-28' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
303
src/ASPBaseOIDC.Web.Ui/src/components/forms/demo-form.tsx
Normal file
303
src/ASPBaseOIDC.Web.Ui/src/components/forms/demo-form.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import { FormInput } from './form-input';
|
||||
import { FormTextarea } from './form-textarea';
|
||||
import { FormSelect, type FormOption } from './form-select';
|
||||
import {
|
||||
FormCheckboxGroup,
|
||||
type CheckboxGroupOption
|
||||
} from './form-checkbox-group';
|
||||
import { FormRadioGroup, type RadioGroupOption } from './form-radio-group';
|
||||
import { FormSwitch } from './form-switch';
|
||||
import { FormSlider } from './form-slider';
|
||||
import { FormDatePicker } from './form-date-picker';
|
||||
import { FormCheckbox } from './form-checkbox';
|
||||
import { FormFileUpload, type FileUploadConfig } from './form-file-upload';
|
||||
|
||||
// Demo form schema
|
||||
const demoFormSchema = z.object({
|
||||
// Basic inputs
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.email('Invalid email address'),
|
||||
age: z.number().min(18, 'Must be at least 18 years old'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
|
||||
// Textarea
|
||||
bio: z.string().min(10, 'Bio must be at least 10 characters'),
|
||||
|
||||
// Select
|
||||
country: z.string().min(1, 'Please select a country'),
|
||||
|
||||
// Checkbox group
|
||||
interests: z.array(z.string()).min(1, 'Select at least one interest'),
|
||||
|
||||
// Radio group
|
||||
gender: z.string().min(1, 'Please select gender'),
|
||||
|
||||
// Switch
|
||||
newsletter: z.boolean(),
|
||||
|
||||
// Slider
|
||||
rating: z.number().min(0).max(10),
|
||||
|
||||
// Date picker
|
||||
birthDate: z.date().optional(),
|
||||
|
||||
// Single checkbox
|
||||
terms: z.boolean().refine((val) => val === true, 'You must accept the terms'),
|
||||
|
||||
// File upload
|
||||
avatar: z.array(z.any()).optional()
|
||||
});
|
||||
|
||||
type DemoFormData = z.infer<typeof demoFormSchema>;
|
||||
|
||||
// Demo options
|
||||
const countryOptions: FormOption[] = [
|
||||
{ value: 'us', label: 'United States' },
|
||||
{ value: 'ca', label: 'Canada' },
|
||||
{ value: 'uk', label: 'United Kingdom' },
|
||||
{ value: 'au', label: 'Australia' },
|
||||
{ value: 'de', label: 'Germany' },
|
||||
{ value: 'fr', label: 'France' }
|
||||
];
|
||||
|
||||
const interestOptions: CheckboxGroupOption[] = [
|
||||
{ value: 'technology', label: 'Technology' },
|
||||
{ value: 'sports', label: 'Sports' },
|
||||
{ value: 'music', label: 'Music' },
|
||||
{ value: 'travel', label: 'Travel' },
|
||||
{ value: 'cooking', label: 'Cooking' },
|
||||
{ value: 'reading', label: 'Reading' }
|
||||
];
|
||||
|
||||
const genderOptions: RadioGroupOption[] = [
|
||||
{ value: 'male', label: 'Male' },
|
||||
{ value: 'female', label: 'Female' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
{ value: 'prefer-not-to-say', label: 'Prefer not to say' }
|
||||
];
|
||||
|
||||
const fileUploadConfig: FileUploadConfig = {
|
||||
maxSize: 5000000, // 5MB
|
||||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
multiple: false,
|
||||
maxFiles: 1
|
||||
};
|
||||
|
||||
export default function DemoForm() {
|
||||
const form = useForm<DemoFormData>({
|
||||
resolver: zodResolver(demoFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
age: 18,
|
||||
password: '',
|
||||
bio: '',
|
||||
country: '',
|
||||
interests: [],
|
||||
gender: '',
|
||||
newsletter: false,
|
||||
rating: 5,
|
||||
birthDate: undefined,
|
||||
terms: false,
|
||||
avatar: []
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: DemoFormData) => {
|
||||
console.log('Form submitted:', data);
|
||||
alert('Form submitted successfully! Check console for data.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mx-auto max-w-2xl space-y-6 p-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl font-bold'>
|
||||
Reusable Form Components Demo
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground'>
|
||||
See how these components reduce boilerplate from 15+ lines to just
|
||||
5-8 lines per field
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
{/* Basic Inputs */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name='name'
|
||||
label='Full Name'
|
||||
placeholder='Enter your full name'
|
||||
required
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name='email'
|
||||
type='email'
|
||||
label='Email Address'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name='age'
|
||||
type='number'
|
||||
label='Age'
|
||||
min={18}
|
||||
max={100}
|
||||
required
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name='password'
|
||||
type='password'
|
||||
label='Password'
|
||||
placeholder='Enter your password'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<FormTextarea
|
||||
control={form.control}
|
||||
name='bio'
|
||||
label='Bio'
|
||||
placeholder='Tell us about yourself...'
|
||||
description='A brief description about yourself'
|
||||
config={{
|
||||
maxLength: 500,
|
||||
showCharCount: true,
|
||||
rows: 4
|
||||
}}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Select */}
|
||||
<FormSelect
|
||||
control={form.control}
|
||||
name='country'
|
||||
label='Country'
|
||||
placeholder='Select your country'
|
||||
options={countryOptions}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Checkbox Group */}
|
||||
<FormCheckboxGroup
|
||||
control={form.control}
|
||||
name='interests'
|
||||
label='Interests'
|
||||
description='Select all that apply'
|
||||
options={interestOptions}
|
||||
columns={3}
|
||||
showBadges={true}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Radio Group */}
|
||||
<FormRadioGroup
|
||||
control={form.control}
|
||||
name='gender'
|
||||
label='Gender'
|
||||
options={genderOptions}
|
||||
orientation='horizontal'
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Switch */}
|
||||
<FormSwitch
|
||||
control={form.control}
|
||||
name='newsletter'
|
||||
label='Subscribe to Newsletter'
|
||||
description='Receive updates about new features and products'
|
||||
/>
|
||||
|
||||
{/* Slider */}
|
||||
<FormSlider
|
||||
control={form.control}
|
||||
name='rating'
|
||||
label='Overall Rating'
|
||||
description='Rate your experience (0-10)'
|
||||
config={{
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 0.5,
|
||||
formatValue: (value) => `${value}/10`
|
||||
}}
|
||||
showValue={true}
|
||||
/>
|
||||
|
||||
{/* Date Picker */}
|
||||
<FormDatePicker
|
||||
control={form.control}
|
||||
name='birthDate'
|
||||
label='Birth Date'
|
||||
description='Your date of birth (optional)'
|
||||
config={{
|
||||
maxDate: new Date(),
|
||||
placeholder: 'Select your birth date'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Single Checkbox */}
|
||||
<FormCheckbox
|
||||
control={form.control}
|
||||
name='terms'
|
||||
checkboxLabel='I agree to the Terms and Conditions'
|
||||
description='Please read and accept our terms'
|
||||
required
|
||||
/>
|
||||
|
||||
{/* File Upload */}
|
||||
<FormFileUpload
|
||||
control={form.control}
|
||||
name='avatar'
|
||||
label='Profile Picture'
|
||||
description='Upload a profile picture (optional)'
|
||||
config={fileUploadConfig}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className='flex gap-4 pt-4'>
|
||||
<Button type='submit' className='flex-1'>
|
||||
Submit Form
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => form.reset()}
|
||||
className='flex-1'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Data Preview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Form Data Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className='bg-muted overflow-auto rounded-lg p-4 text-sm'>
|
||||
{JSON.stringify(form.watch(), null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BaseFormFieldProps, CheckboxGroupOption } from '@/types/base-form';
|
||||
|
||||
interface FormCheckboxGroupProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
options: CheckboxGroupOption[];
|
||||
showBadges?: boolean;
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
||||
function FormCheckboxGroup<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
options,
|
||||
showBadges = true,
|
||||
columns = 2,
|
||||
disabled,
|
||||
className
|
||||
}: FormCheckboxGroupProps<TFieldValues, TName>) {
|
||||
const gridCols = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
|
||||
};
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<div className={`grid gap-4 ${gridCols[columns]}`}>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className='flex items-center space-x-2'>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id={`${name}-${option.value}`}
|
||||
checked={field.value?.includes(option.value) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentValues = field.value || [];
|
||||
if (checked) {
|
||||
field.onChange([...currentValues, option.value]);
|
||||
} else {
|
||||
field.onChange(
|
||||
currentValues.filter(
|
||||
(value: string) => value !== option.value
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || option.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<label
|
||||
htmlFor={`${name}-${option.value}`}
|
||||
className='text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showBadges && field.value && field.value.length > 0 && (
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{field.value.map((value: string) => {
|
||||
const option = options.find((opt) => opt.value === value);
|
||||
return (
|
||||
<Badge key={value} variant='secondary'>
|
||||
{option?.label || value}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormCheckboxGroup, type CheckboxGroupOption };
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { BaseFormFieldProps } from '@/types/base-form';
|
||||
|
||||
interface FormCheckboxProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
checkboxLabel?: string;
|
||||
}
|
||||
|
||||
function FormCheckbox<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
checkboxLabel,
|
||||
disabled,
|
||||
className
|
||||
}: FormCheckboxProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={`flex flex-row items-start space-y-0 space-x-3 ${className}`}
|
||||
>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='space-y-1 leading-none'>
|
||||
<FormLabel>
|
||||
{checkboxLabel || label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormCheckbox };
|
||||
105
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-date-picker.tsx
Normal file
105
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-date-picker.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from '@/components/ui/popover';
|
||||
import { BaseFormFieldProps, DatePickerConfig } from '@/types/base-form';
|
||||
|
||||
interface FormDatePickerProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
config?: DatePickerConfig;
|
||||
}
|
||||
|
||||
function FormDatePicker<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
config = {},
|
||||
disabled,
|
||||
className
|
||||
}: FormDatePickerProps<TFieldValues, TName>) {
|
||||
const {
|
||||
minDate,
|
||||
maxDate,
|
||||
disabledDates = [],
|
||||
placeholder = 'Pick a date'
|
||||
} = config;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={`flex flex-col ${className}`}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={`w-full pl-3 text-left font-normal ${
|
||||
!field.value && 'text-muted-foreground'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, 'PPP')
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
<CalendarIcon className='ml-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date) => {
|
||||
if (minDate && date < minDate) return true;
|
||||
if (maxDate && date > maxDate) return true;
|
||||
return disabledDates.some(
|
||||
(disabledDate) => date.getTime() === disabledDate.getTime()
|
||||
);
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormDatePicker };
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { BaseFormFieldProps, FileUploadConfig } from '@/types/base-form';
|
||||
import { FileUploader, FileUploaderProps } from '@/components/file-uploader';
|
||||
|
||||
interface FormFileUploadProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
config?: FileUploadConfig;
|
||||
}
|
||||
|
||||
function FormFileUpload<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
config,
|
||||
disabled,
|
||||
className
|
||||
}: FormFileUploadProps<TFieldValues, TName>) {
|
||||
const {
|
||||
maxSize,
|
||||
acceptedTypes,
|
||||
multiple,
|
||||
maxFiles,
|
||||
onUpload,
|
||||
progresses,
|
||||
...restConfig
|
||||
} = config || {};
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onUpload={onUpload}
|
||||
progresses={progresses}
|
||||
accept={acceptedTypes?.reduce(
|
||||
(acc, type) => ({ ...acc, [type]: [] }),
|
||||
{}
|
||||
)}
|
||||
maxSize={maxSize}
|
||||
maxFiles={maxFiles}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
{...restConfig}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormFileUpload, type FileUploadConfig };
|
||||
82
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-input.tsx
Normal file
82
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-input.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { BaseFormFieldProps } from '@/types/base-form';
|
||||
|
||||
interface FormInputProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
||||
placeholder?: string;
|
||||
step?: string | number;
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
}
|
||||
|
||||
function FormInput<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
disabled,
|
||||
className
|
||||
}: FormInputProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<FormControl>
|
||||
<Input
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
if (type === 'number') {
|
||||
const value = e.target.value;
|
||||
field.onChange(value === '' ? undefined : parseFloat(value));
|
||||
} else {
|
||||
field.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormInput };
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BaseFormFieldProps, RadioGroupOption } from '@/types/base-form';
|
||||
|
||||
interface FormRadioGroupProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
options: RadioGroupOption[];
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
function FormRadioGroup<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
options,
|
||||
orientation = 'vertical',
|
||||
disabled,
|
||||
className
|
||||
}: FormRadioGroupProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={disabled}
|
||||
className={
|
||||
orientation === 'horizontal'
|
||||
? 'flex flex-row space-x-6'
|
||||
: 'space-y-2'
|
||||
}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className='flex items-center space-x-2'>
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
id={`${name}-${option.value}`}
|
||||
disabled={option.disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${name}-${option.value}`}
|
||||
className='text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormRadioGroup, type RadioGroupOption };
|
||||
86
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-select.tsx
Normal file
86
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-select.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { BaseFormFieldProps, FormOption } from '@/types/base-form';
|
||||
|
||||
interface FormSelectProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
options: FormOption[];
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
function FormSelect<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
options,
|
||||
placeholder = 'Select an option',
|
||||
disabled,
|
||||
className
|
||||
}: FormSelectProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormSelect, type FormOption };
|
||||
82
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-slider.tsx
Normal file
82
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-slider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { BaseFormFieldProps, SliderConfig } from '@/types/base-form';
|
||||
|
||||
interface FormSliderProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
config: SliderConfig;
|
||||
showValue?: boolean;
|
||||
}
|
||||
|
||||
function FormSlider<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
config,
|
||||
showValue = true,
|
||||
disabled,
|
||||
className
|
||||
}: FormSliderProps<TFieldValues, TName>) {
|
||||
const { min, max, step = 1, formatValue } = config;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<FormControl>
|
||||
<div className='px-3'>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[field.value || min]}
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showValue && (
|
||||
<div className='text-muted-foreground mt-1 flex justify-between text-sm'>
|
||||
<span>{formatValue ? formatValue(min) : min}</span>
|
||||
<span>
|
||||
{formatValue
|
||||
? formatValue(field.value || min)
|
||||
: field.value || min}
|
||||
</span>
|
||||
<span>{formatValue ? formatValue(max) : max}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormSlider };
|
||||
65
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-switch.tsx
Normal file
65
src/ASPBaseOIDC.Web.Ui/src/components/forms/form-switch.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { BaseFormFieldProps } from '@/types/base-form';
|
||||
|
||||
interface FormSwitchProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
function FormSwitch<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
showDescription = true,
|
||||
disabled,
|
||||
className
|
||||
}: FormSwitchProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={`flex flex-row items-center justify-between rounded-lg border p-4 ${className}`}
|
||||
>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
{showDescription && description && (
|
||||
<FormDescription>{description}</FormDescription>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormSwitch };
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { BaseFormFieldProps, TextareaConfig } from '@/types/base-form';
|
||||
|
||||
interface FormTextareaProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
placeholder?: string;
|
||||
config?: TextareaConfig;
|
||||
}
|
||||
|
||||
function FormTextarea<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
placeholder,
|
||||
config = {},
|
||||
disabled,
|
||||
className
|
||||
}: FormTextareaProps<TFieldValues, TName>) {
|
||||
const {
|
||||
maxLength,
|
||||
showCharCount = true,
|
||||
rows = 4,
|
||||
resize = 'vertical'
|
||||
} = config;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<FormControl>
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
style={{ resize }}
|
||||
maxLength={maxLength}
|
||||
{...field}
|
||||
/>
|
||||
{showCharCount && maxLength && (
|
||||
<div className='text-muted-foreground text-right text-sm'>
|
||||
{field.value?.length || 0} / {maxLength}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormTextarea };
|
||||
70
src/ASPBaseOIDC.Web.Ui/src/components/icons.tsx
Normal file
70
src/ASPBaseOIDC.Web.Ui/src/components/icons.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconCommand,
|
||||
IconCreditCard,
|
||||
IconFile,
|
||||
IconFileText,
|
||||
IconHelpCircle,
|
||||
IconPhoto,
|
||||
IconDeviceLaptop,
|
||||
IconLayoutDashboard,
|
||||
IconLoader2,
|
||||
IconLogin,
|
||||
IconProps,
|
||||
IconShoppingBag,
|
||||
IconMoon,
|
||||
IconDotsVertical,
|
||||
IconPizza,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconTrash,
|
||||
IconBrandTwitter,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUserEdit,
|
||||
IconUserX,
|
||||
IconX,
|
||||
IconLayoutKanban,
|
||||
IconBrandGithub
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export type Icon = React.ComponentType<IconProps>;
|
||||
|
||||
export const Icons = {
|
||||
dashboard: IconLayoutDashboard,
|
||||
logo: IconCommand,
|
||||
login: IconLogin,
|
||||
close: IconX,
|
||||
product: IconShoppingBag,
|
||||
spinner: IconLoader2,
|
||||
kanban: IconLayoutKanban,
|
||||
chevronLeft: IconChevronLeft,
|
||||
chevronRight: IconChevronRight,
|
||||
trash: IconTrash,
|
||||
employee: IconUserX,
|
||||
post: IconFileText,
|
||||
page: IconFile,
|
||||
userPen: IconUserEdit,
|
||||
user2: IconUserCircle,
|
||||
media: IconPhoto,
|
||||
settings: IconSettings,
|
||||
billing: IconCreditCard,
|
||||
ellipsis: IconDotsVertical,
|
||||
add: IconPlus,
|
||||
warning: IconAlertTriangle,
|
||||
user: IconUser,
|
||||
arrowRight: IconArrowRight,
|
||||
help: IconHelpCircle,
|
||||
pizza: IconPizza,
|
||||
sun: IconSun,
|
||||
moon: IconMoon,
|
||||
laptop: IconDeviceLaptop,
|
||||
github: IconBrandGithub,
|
||||
twitter: IconBrandTwitter,
|
||||
check: IconCheck
|
||||
};
|
||||
83
src/ASPBaseOIDC.Web.Ui/src/components/kbar/index.tsx
Normal file
83
src/ASPBaseOIDC.Web.Ui/src/components/kbar/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
import { navItems } from '@/constants/data';
|
||||
import {
|
||||
KBarAnimator,
|
||||
KBarPortal,
|
||||
KBarPositioner,
|
||||
KBarProvider,
|
||||
KBarSearch
|
||||
} from 'kbar';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import RenderResults from './render-result';
|
||||
import useThemeSwitching from './use-theme-switching';
|
||||
|
||||
export default function KBar({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
|
||||
// These action are for the navigation
|
||||
const actions = useMemo(() => {
|
||||
// Define navigateTo inside the useMemo callback to avoid dependency array issues
|
||||
const navigateTo = (url: string) => {
|
||||
router.push(url);
|
||||
};
|
||||
|
||||
return navItems.flatMap((navItem) => {
|
||||
// Only include base action if the navItem has a real URL and is not just a container
|
||||
const baseAction =
|
||||
navItem.url !== '#'
|
||||
? {
|
||||
id: `${navItem.title.toLowerCase()}Action`,
|
||||
name: navItem.title,
|
||||
shortcut: navItem.shortcut,
|
||||
keywords: navItem.title.toLowerCase(),
|
||||
section: 'Navigation',
|
||||
subtitle: `Go to ${navItem.title}`,
|
||||
perform: () => navigateTo(navItem.url)
|
||||
}
|
||||
: null;
|
||||
|
||||
// Map child items into actions
|
||||
const childActions =
|
||||
navItem.items?.map((childItem) => ({
|
||||
id: `${childItem.title.toLowerCase()}Action`,
|
||||
name: childItem.title,
|
||||
shortcut: childItem.shortcut,
|
||||
keywords: childItem.title.toLowerCase(),
|
||||
section: navItem.title,
|
||||
subtitle: `Go to ${childItem.title}`,
|
||||
perform: () => navigateTo(childItem.url)
|
||||
})) ?? [];
|
||||
|
||||
// Return only valid actions (ignoring null base actions for containers)
|
||||
return baseAction ? [baseAction, ...childActions] : childActions;
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<KBarProvider actions={actions}>
|
||||
<KBarComponent>{children}</KBarComponent>
|
||||
</KBarProvider>
|
||||
);
|
||||
}
|
||||
const KBarComponent = ({ children }: { children: React.ReactNode }) => {
|
||||
useThemeSwitching();
|
||||
|
||||
return (
|
||||
<>
|
||||
<KBarPortal>
|
||||
<KBarPositioner className='bg-background/80 fixed inset-0 z-99999 p-0! backdrop-blur-sm'>
|
||||
<KBarAnimator className='bg-card text-card-foreground relative mt-64! w-full max-w-[600px] -translate-y-12! overflow-hidden rounded-lg border shadow-lg'>
|
||||
<div className='bg-card border-border sticky top-0 z-10 border-b'>
|
||||
<KBarSearch className='bg-card w-full border-none px-6 py-4 text-lg outline-hidden focus:ring-0 focus:ring-offset-0 focus:outline-hidden' />
|
||||
</div>
|
||||
<div className='max-h-[400px]'>
|
||||
<RenderResults />
|
||||
</div>
|
||||
</KBarAnimator>
|
||||
</KBarPositioner>
|
||||
</KBarPortal>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
25
src/ASPBaseOIDC.Web.Ui/src/components/kbar/render-result.tsx
Normal file
25
src/ASPBaseOIDC.Web.Ui/src/components/kbar/render-result.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { KBarResults, useMatches } from 'kbar';
|
||||
import ResultItem from './result-item';
|
||||
|
||||
export default function RenderResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
|
||||
return (
|
||||
<KBarResults
|
||||
items={results}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === 'string' ? (
|
||||
<div className='text-primary-foreground px-4 py-2 text-sm uppercase opacity-50'>
|
||||
{item}
|
||||
</div>
|
||||
) : (
|
||||
<ResultItem
|
||||
action={item}
|
||||
active={active}
|
||||
currentRootActionId={rootActionId ?? ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
src/ASPBaseOIDC.Web.Ui/src/components/kbar/result-item.tsx
Normal file
77
src/ASPBaseOIDC.Web.Ui/src/components/kbar/result-item.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ActionId, ActionImpl } from 'kbar';
|
||||
import * as React from 'react';
|
||||
|
||||
const ResultItem = React.forwardRef(
|
||||
(
|
||||
{
|
||||
action,
|
||||
active,
|
||||
currentRootActionId
|
||||
}: {
|
||||
action: ActionImpl;
|
||||
active: boolean;
|
||||
currentRootActionId: ActionId;
|
||||
},
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
const ancestors = React.useMemo(() => {
|
||||
if (!currentRootActionId) return action.ancestors;
|
||||
const index = action.ancestors.findIndex(
|
||||
(ancestor) => ancestor.id === currentRootActionId
|
||||
);
|
||||
return action.ancestors.slice(index + 1);
|
||||
}, [action.ancestors, currentRootActionId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative z-10 flex cursor-pointer items-center justify-between px-4 py-3`}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
id='kbar-result-item'
|
||||
className='border-primary bg-accent/50 absolute inset-0 z-[-1]! border-l-4'
|
||||
></div>
|
||||
)}
|
||||
<div className='relative z-10 flex items-center gap-2'>
|
||||
{action.icon && action.icon}
|
||||
<div className='flex flex-col'>
|
||||
<div>
|
||||
{ancestors.length > 0 &&
|
||||
ancestors.map((ancestor) => (
|
||||
<React.Fragment key={ancestor.id}>
|
||||
<span className='text-muted-foreground mr-2'>
|
||||
{ancestor.name}
|
||||
</span>
|
||||
<span className='mr-2'>›</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span>{action.name}</span>
|
||||
</div>
|
||||
{action.subtitle && (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{action.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{action.shortcut?.length ? (
|
||||
<div className='relative z-10 grid grid-flow-col gap-1'>
|
||||
{action.shortcut.map((sc, i) => (
|
||||
<kbd
|
||||
key={sc + i}
|
||||
className='bg-muted flex h-5 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium'
|
||||
>
|
||||
{sc}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ResultItem.displayName = 'KBarResultItem';
|
||||
|
||||
export default ResultItem;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useRegisterActions } from 'kbar';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
const useThemeSwitching = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
const themeAction = [
|
||||
{
|
||||
id: 'toggleTheme',
|
||||
name: 'Toggle Theme',
|
||||
shortcut: ['t', 't'],
|
||||
section: 'Theme',
|
||||
perform: toggleTheme
|
||||
},
|
||||
{
|
||||
id: 'setLightTheme',
|
||||
name: 'Set Light Theme',
|
||||
section: 'Theme',
|
||||
perform: () => setTheme('light')
|
||||
},
|
||||
{
|
||||
id: 'setDarkTheme',
|
||||
name: 'Set Dark Theme',
|
||||
section: 'Theme',
|
||||
perform: () => setTheme('dark')
|
||||
}
|
||||
];
|
||||
|
||||
useRegisterActions(themeAction, [theme]);
|
||||
};
|
||||
|
||||
export default useThemeSwitching;
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
ThemeProviderProps
|
||||
} from 'next-themes';
|
||||
|
||||
export default function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { IconBrightness } from '@tabler/icons-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const handleThemeToggle = React.useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
const newMode = resolvedTheme === 'dark' ? 'light' : 'dark';
|
||||
const root = document.documentElement;
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
setTheme(newMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set coordinates from the click event
|
||||
if (e) {
|
||||
root.style.setProperty('--x', `${e.clientX}px`);
|
||||
root.style.setProperty('--y', `${e.clientY}px`);
|
||||
}
|
||||
|
||||
document.startViewTransition(() => {
|
||||
setTheme(newMode);
|
||||
});
|
||||
},
|
||||
[resolvedTheme, setTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='icon'
|
||||
className='group/toggle size-8'
|
||||
onClick={handleThemeToggle}
|
||||
>
|
||||
<IconBrightness />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
213
src/ASPBaseOIDC.Web.Ui/src/components/layout/app-sidebar.tsx
Normal file
213
src/ASPBaseOIDC.Web.Ui/src/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail
|
||||
} from '@/components/ui/sidebar';
|
||||
import { UserAvatarProfile } from '@/components/user-avatar-profile';
|
||||
import { navItems } from '@/constants/data';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
import { useUser } from '@clerk/nextjs';
|
||||
import {
|
||||
IconBell,
|
||||
IconChevronRight,
|
||||
IconChevronsDown,
|
||||
IconCreditCard,
|
||||
IconLogout,
|
||||
IconPhotoUp,
|
||||
IconUserCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { SignOutButton } from '@clerk/nextjs';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { Icons } from '../icons';
|
||||
import { OrgSwitcher } from '../org-switcher';
|
||||
export const company = {
|
||||
name: 'Acme Inc',
|
||||
logo: IconPhotoUp,
|
||||
plan: 'Enterprise'
|
||||
};
|
||||
|
||||
const tenants = [
|
||||
{ id: '1', name: 'Acme Inc' },
|
||||
{ id: '2', name: 'Beta Corp' },
|
||||
{ id: '3', name: 'Gamma Ltd' }
|
||||
];
|
||||
|
||||
export default function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isOpen } = useMediaQuery();
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const handleSwitchTenant = (_tenantId: string) => {
|
||||
// Tenant switching functionality would be implemented here
|
||||
};
|
||||
|
||||
const activeTenant = tenants[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
// Side effects based on sidebar state changes
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible='icon'>
|
||||
<SidebarHeader>
|
||||
<OrgSwitcher
|
||||
tenants={tenants}
|
||||
defaultTenant={activeTenant}
|
||||
onTenantSwitch={handleSwitchTenant}
|
||||
/>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className='overflow-x-hidden'>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Overview</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
||||
return item?.items && item?.items?.length > 0 ? (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
{item.icon && <Icon />}
|
||||
<span>{item.title}</span>
|
||||
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === subItem.url}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
{user && (
|
||||
<UserAvatarProfile
|
||||
className='h-8 w-8 rounded-lg'
|
||||
showInfo
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
<IconChevronsDown className='ml-auto size-4' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='p-0 font-normal'>
|
||||
<div className='px-1 py-1.5'>
|
||||
{user && (
|
||||
<UserAvatarProfile
|
||||
className='h-8 w-8 rounded-lg'
|
||||
showInfo
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push('/dashboard/profile')}
|
||||
>
|
||||
<IconUserCircle className='mr-2 h-4 w-4' />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconCreditCard className='mr-2 h-4 w-4' />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconBell className='mr-2 h-4 w-4' />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<IconLogout className='mr-2 h-4 w-4' />
|
||||
<SignOutButton redirectUrl='/auth/sign-in' />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
18
src/ASPBaseOIDC.Web.Ui/src/components/layout/cta-github.tsx
Normal file
18
src/ASPBaseOIDC.Web.Ui/src/components/layout/cta-github.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IconBrandGithub } from '@tabler/icons-react';
|
||||
|
||||
export default function CtaGithub() {
|
||||
return (
|
||||
<Button variant='ghost' asChild size='sm' className='hidden sm:flex'>
|
||||
<a
|
||||
href='https://github.com/Kiranism/next-shadcn-dashboard-starter'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
className='dark:text-foreground'
|
||||
>
|
||||
<IconBrandGithub />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
31
src/ASPBaseOIDC.Web.Ui/src/components/layout/header.tsx
Normal file
31
src/ASPBaseOIDC.Web.Ui/src/components/layout/header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { SidebarTrigger } from '../ui/sidebar';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Breadcrumbs } from '../breadcrumbs';
|
||||
import SearchInput from '../search-input';
|
||||
import { UserNav } from './user-nav';
|
||||
import { ThemeSelector } from '../theme-selector';
|
||||
import { ModeToggle } from './ThemeToggle/theme-toggle';
|
||||
import CtaGithub from './cta-github';
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className='flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12'>
|
||||
<div className='flex items-center gap-2 px-4'>
|
||||
<SidebarTrigger className='-ml-1' />
|
||||
<Separator orientation='vertical' className='mr-2 h-4' />
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2 px-4'>
|
||||
<CtaGithub />
|
||||
<div className='hidden md:flex'>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<UserNav />
|
||||
<ModeToggle />
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export default function PageContainer({
|
||||
children,
|
||||
scrollable = true
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scrollable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{scrollable ? (
|
||||
<ScrollArea className='h-[calc(100dvh-52px)]'>
|
||||
<div className='flex flex-1 p-4 md:px-6'>{children}</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className='flex flex-1 p-4 md:px-6'>{children}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/ASPBaseOIDC.Web.Ui/src/components/layout/providers.tsx
Normal file
31
src/ASPBaseOIDC.Web.Ui/src/components/layout/providers.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
import { ClerkProvider } from '@clerk/nextjs';
|
||||
import { dark } from '@clerk/themes';
|
||||
import { useTheme } from 'next-themes';
|
||||
import React from 'react';
|
||||
import { ActiveThemeProvider } from '../active-theme';
|
||||
|
||||
export default function Providers({
|
||||
activeThemeValue,
|
||||
children
|
||||
}: {
|
||||
activeThemeValue: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// we need the resolvedTheme value to set the baseTheme for clerk based on the dark or light theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActiveThemeProvider initialTheme={activeThemeValue}>
|
||||
<ClerkProvider
|
||||
appearance={{
|
||||
baseTheme: resolvedTheme === 'dark' ? dark : undefined
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ClerkProvider>
|
||||
</ActiveThemeProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/ASPBaseOIDC.Web.Ui/src/components/layout/user-nav.tsx
Normal file
59
src/ASPBaseOIDC.Web.Ui/src/components/layout/user-nav.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UserAvatarProfile } from '@/components/user-avatar-profile';
|
||||
import { SignOutButton, useUser } from '@clerk/nextjs';
|
||||
import { useRouter } from 'next/navigation';
|
||||
export function UserNav() {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
if (user) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
|
||||
<UserAvatarProfile user={user} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-56'
|
||||
align='end'
|
||||
sideOffset={10}
|
||||
forceMount
|
||||
>
|
||||
<DropdownMenuLabel className='font-normal'>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<p className='text-sm leading-none font-medium'>
|
||||
{user.fullName}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs leading-none'>
|
||||
{user.emailAddresses[0].emailAddress}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push('/dashboard/profile')}>
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>Billing</DropdownMenuItem>
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem>New Team</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<SignOutButton redirectUrl='/auth/sign-in' />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/ASPBaseOIDC.Web.Ui/src/components/modal/alert-modal.tsx
Normal file
46
src/ASPBaseOIDC.Web.Ui/src/components/modal/alert-modal.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modal } from '@/components/ui/modal';
|
||||
|
||||
interface AlertModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const AlertModal: React.FC<AlertModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title='Are you sure?'
|
||||
description='This action cannot be undone.'
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className='flex w-full items-center justify-end space-x-2 pt-6'>
|
||||
<Button disabled={loading} variant='outline' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={loading} variant='destructive' onClick={onConfirm}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
80
src/ASPBaseOIDC.Web.Ui/src/components/nav-main.tsx
Normal file
80
src/ASPBaseOIDC.Web.Ui/src/components/nav-main.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Icon } from '@/components/icons';
|
||||
|
||||
export function NavMain({
|
||||
items
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarGroupContent className='flex flex-col gap-2'>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
className='bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear'
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
89
src/ASPBaseOIDC.Web.Ui/src/components/nav-projects.tsx
Normal file
89
src/ASPBaseOIDC.Web.Ui/src/components/nav-projects.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
IconFolder,
|
||||
IconShare,
|
||||
IconDots,
|
||||
IconTrash
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Icon } from '@/components/icons';
|
||||
|
||||
export function NavProjects({
|
||||
projects
|
||||
}: {
|
||||
projects: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: Icon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarGroup className='group-data-[collapsible=icon]:hidden'>
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{projects.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<IconDots />
|
||||
<span className='sr-only'>More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-48 rounded-lg'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align={isMobile ? 'end' : 'start'}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<IconFolder className='text-muted-foreground mr-2 h-4 w-4' />
|
||||
<span>View Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconShare className='text-muted-foreground mr-2 h-4 w-4' />
|
||||
<span>Share Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<IconTrash className='text-muted-foreground mr-2 h-4 w-4' />
|
||||
<span>Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className='text-sidebar-foreground/70'>
|
||||
<IconDots className='text-sidebar-foreground/70' />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
110
src/ASPBaseOIDC.Web.Ui/src/components/nav-user.tsx
Normal file
110
src/ASPBaseOIDC.Web.Ui/src/components/nav-user.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconBell,
|
||||
IconChevronsDown,
|
||||
IconCreditCard,
|
||||
IconLogout,
|
||||
IconSparkles
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
export function NavUser({
|
||||
user
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.name}</span>
|
||||
<span className='truncate text-xs'>{user.email}</span>
|
||||
</div>
|
||||
<IconChevronsDown className='ml-auto size-4' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='p-0 font-normal'>
|
||||
<div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.name}</span>
|
||||
<span className='truncate text-xs'>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<IconSparkles className='mr-2 h-4 w-4' />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<IconCircleCheck className='mr-2 h-4 w-4' />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconCreditCard className='mr-2 h-4 w-4' />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconBell className='mr-2 h-4 w-4' />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<IconLogout className='mr-2 h-4 w-4' />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
85
src/ASPBaseOIDC.Web.Ui/src/components/org-switcher.tsx
Normal file
85
src/ASPBaseOIDC.Web.Ui/src/components/org-switcher.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { Check, ChevronsUpDown, GalleryVerticalEnd } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function OrgSwitcher({
|
||||
tenants,
|
||||
defaultTenant,
|
||||
onTenantSwitch
|
||||
}: {
|
||||
tenants: Tenant[];
|
||||
defaultTenant: Tenant;
|
||||
onTenantSwitch?: (tenantId: string) => void;
|
||||
}) {
|
||||
const [selectedTenant, setSelectedTenant] = React.useState<
|
||||
Tenant | undefined
|
||||
>(defaultTenant || (tenants.length > 0 ? tenants[0] : undefined));
|
||||
|
||||
const handleTenantSwitch = (tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
if (onTenantSwitch) {
|
||||
onTenantSwitch(tenant.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedTenant) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<div className='bg-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
|
||||
<GalleryVerticalEnd className='size-4' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5 leading-none'>
|
||||
<span className='font-semibold'>Next Starter</span>
|
||||
<span className=''>{selectedTenant.name}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ml-auto' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-[--radix-dropdown-menu-trigger-width]'
|
||||
align='start'
|
||||
>
|
||||
{tenants.map((tenant) => (
|
||||
<DropdownMenuItem
|
||||
key={tenant.id}
|
||||
onSelect={() => handleTenantSwitch(tenant)}
|
||||
>
|
||||
{tenant.name}{' '}
|
||||
{tenant.id === selectedTenant.id && (
|
||||
<Check className='ml-auto' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
23
src/ASPBaseOIDC.Web.Ui/src/components/search-input.tsx
Normal file
23
src/ASPBaseOIDC.Web.Ui/src/components/search-input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
import { useKBar } from 'kbar';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export default function SearchInput() {
|
||||
const { query } = useKBar();
|
||||
return (
|
||||
<div className='w-full space-y-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='bg-background text-muted-foreground relative h-9 w-full justify-start rounded-[0.5rem] text-sm font-normal shadow-none sm:pr-12 md:w-40 lg:w-64'
|
||||
onClick={query.toggle}
|
||||
>
|
||||
<IconSearch className='mr-2 h-4 w-4' />
|
||||
Search...
|
||||
<kbd className='bg-muted pointer-events-none absolute top-[0.3rem] right-[0.3rem] hidden h-6 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none sm:flex'>
|
||||
<span className='text-xs'>⌘</span>K
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/ASPBaseOIDC.Web.Ui/src/components/theme-selector.tsx
Normal file
102
src/ASPBaseOIDC.Web.Ui/src/components/theme-selector.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useThemeConfig } from '@/components/active-theme';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const DEFAULT_THEMES = [
|
||||
{
|
||||
name: 'Default',
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
value: 'blue'
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
value: 'green'
|
||||
},
|
||||
{
|
||||
name: 'Amber',
|
||||
value: 'amber'
|
||||
}
|
||||
];
|
||||
|
||||
const SCALED_THEMES = [
|
||||
{
|
||||
name: 'Default',
|
||||
value: 'default-scaled'
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
value: 'blue-scaled'
|
||||
}
|
||||
];
|
||||
|
||||
const MONO_THEMES = [
|
||||
{
|
||||
name: 'Mono',
|
||||
value: 'mono-scaled'
|
||||
}
|
||||
];
|
||||
|
||||
export function ThemeSelector() {
|
||||
const { activeTheme, setActiveTheme } = useThemeConfig();
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='theme-selector' className='sr-only'>
|
||||
Theme
|
||||
</Label>
|
||||
<Select value={activeTheme} onValueChange={setActiveTheme}>
|
||||
<SelectTrigger
|
||||
id='theme-selector'
|
||||
className='justify-start *:data-[slot=select-value]:w-12'
|
||||
>
|
||||
<span className='text-muted-foreground hidden sm:block'>
|
||||
Select a theme:
|
||||
</span>
|
||||
<span className='text-muted-foreground block sm:hidden'>Theme</span>
|
||||
<SelectValue placeholder='Select a theme' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='end'>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
{DEFAULT_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Scaled</SelectLabel>
|
||||
{SCALED_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Monospaced</SelectLabel>
|
||||
{MONO_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/ASPBaseOIDC.Web.Ui/src/components/ui/accordion.tsx
Normal file
66
src/ASPBaseOIDC.Web.Ui/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot='accordion' {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot='accordion-item'
|
||||
className={cn('border-b last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className='flex'>
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot='accordion-trigger'
|
||||
className={cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className='text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200' />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot='accordion-content'
|
||||
className='data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm'
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
157
src/ASPBaseOIDC.Web.Ui/src/components/ui/alert-dialog.tsx
Normal file
157
src/ASPBaseOIDC.Web.Ui/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot='alert-dialog-overlay'
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot='alert-dialog-content'
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-dialog-header'
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-dialog-footer'
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot='alert-dialog-title'
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot='alert-dialog-description'
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel
|
||||
};
|
||||
66
src/ASPBaseOIDC.Web.Ui/src/components/ui/alert.tsx
Normal file
66
src/ASPBaseOIDC.Web.Ui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert'
|
||||
role='alert'
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-title'
|
||||
className={cn(
|
||||
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-description'
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
11
src/ASPBaseOIDC.Web.Ui/src/components/ui/aspect-ratio.tsx
Normal file
11
src/ASPBaseOIDC.Web.Ui/src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot='aspect-ratio' {...props} />;
|
||||
}
|
||||
|
||||
export { AspectRatio };
|
||||
53
src/ASPBaseOIDC.Web.Ui/src/components/ui/avatar.tsx
Normal file
53
src/ASPBaseOIDC.Web.Ui/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot='avatar'
|
||||
className={cn(
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot='avatar-image'
|
||||
className={cn('aspect-square size-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot='avatar-fallback'
|
||||
className={cn(
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
46
src/ASPBaseOIDC.Web.Ui/src/components/ui/badge.tsx
Normal file
46
src/ASPBaseOIDC.Web.Ui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot='badge'
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
109
src/ASPBaseOIDC.Web.Ui/src/components/ui/breadcrumb.tsx
Normal file
109
src/ASPBaseOIDC.Web.Ui/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label='breadcrumb' data-slot='breadcrumb' {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot='breadcrumb-item'
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot='breadcrumb-link'
|
||||
className={cn('hover:text-foreground transition-colors', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot='breadcrumb-page'
|
||||
role='link'
|
||||
aria-disabled='true'
|
||||
aria-current='page'
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot='breadcrumb-separator'
|
||||
role='presentation'
|
||||
aria-hidden='true'
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<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
|
||||
};
|
||||
59
src/ASPBaseOIDC.Web.Ui/src/components/ui/button.tsx
Normal file
59
src/ASPBaseOIDC.Web.Ui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot='button'
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
76
src/ASPBaseOIDC.Web.Ui/src/components/ui/calendar.tsx
Normal file
76
src/ASPBaseOIDC.Web.Ui/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons';
|
||||
|
||||
// Custom icons that meet the DayPicker requirements
|
||||
const LeftIcon = () => <ChevronLeftIcon className='size-4' />;
|
||||
const RightIcon = () => <ChevronRightIcon className='size-4' />;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: ComponentProps<typeof DayPicker>) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row gap-2',
|
||||
month: 'flex flex-col gap-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center w-full',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'flex items-center gap-1',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-x-1',
|
||||
head_row: 'flex',
|
||||
head_cell:
|
||||
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: cn(
|
||||
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md',
|
||||
props.mode === 'range'
|
||||
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
|
||||
: '[&:has([aria-selected])]:rounded-md'
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'size-8 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
day_range_start:
|
||||
'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||
day_range_end:
|
||||
'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
'day-outside text-muted-foreground aria-selected:text-muted-foreground',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
...classNames
|
||||
}}
|
||||
components={{
|
||||
IconLeft: LeftIcon,
|
||||
IconRight: RightIcon
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar };
|
||||
92
src/ASPBaseOIDC.Web.Ui/src/components/ui/card.tsx
Normal file
92
src/ASPBaseOIDC.Web.Ui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card'
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-header'
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-title'
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-description'
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-action'
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-content'
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-footer'
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent
|
||||
};
|
||||
354
src/ASPBaseOIDC.Web.Ui/src/components/ui/chart.tsx
Normal file
354
src/ASPBaseOIDC.Web.Ui/src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as RechartsPrimitive from 'recharts';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[key in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function 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} />
|
||||
{/* adding debounce will fix chart laggy behavior while animating */}
|
||||
<RechartsPrimitive.ResponsiveContainer debounce={2000}>
|
||||
{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(([configKey, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${configKey}: ${color};` : null;
|
||||
})
|
||||
.join('\n')}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join('\n')
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function 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 as keyof typeof config]?.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-[8rem] 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.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;
|
||||
|
||||
function 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.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>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function 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 as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle
|
||||
};
|
||||
32
src/ASPBaseOIDC.Web.Ui/src/components/ui/checkbox.tsx
Normal file
32
src/ASPBaseOIDC.Web.Ui/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function 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}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
33
src/ASPBaseOIDC.Web.Ui/src/components/ui/collapsible.tsx
Normal file
33
src/ASPBaseOIDC.Web.Ui/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot='collapsible' {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot='collapsible-trigger'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot='collapsible-content'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
177
src/ASPBaseOIDC.Web.Ui/src/components/ui/command.tsx
Normal file
177
src/ASPBaseOIDC.Web.Ui/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot='command'
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className='sr-only'>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className='overflow-hidden p-0'>
|
||||
<Command className='[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='command-input-wrapper'
|
||||
className='flex h-9 items-center gap-2 border-b px-3'
|
||||
>
|
||||
<SearchIcon className='size-4 shrink-0 opacity-50' />
|
||||
<CommandPrimitive.Input
|
||||
data-slot='command-input'
|
||||
className={cn(
|
||||
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot='command-list'
|
||||
className={cn(
|
||||
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot='command-empty'
|
||||
className='py-6 text-center text-sm'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot='command-group'
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot='command-separator'
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot='command-item'
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot='command-shortcut'
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator
|
||||
};
|
||||
252
src/ASPBaseOIDC.Web.Ui/src/components/ui/context-menu.tsx
Normal file
252
src/ASPBaseOIDC.Web.Ui/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot='context-menu-radio-group'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot='context-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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className='ml-auto' />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot='context-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-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot='context-menu-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 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<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 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot='context-menu-checkbox-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className='size-4' />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot='context-menu-radio-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className='size-2 fill-current' />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot='context-menu-label'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot='context-menu-separator'
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot='context-menu-shortcut'
|
||||
className={cn(
|
||||
'text-muted-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
|
||||
};
|
||||
135
src/ASPBaseOIDC.Web.Ui/src/components/ui/dialog.tsx
Normal file
135
src/ASPBaseOIDC.Web.Ui/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot='dialog' {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot='dialog-overlay'
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot='dialog-portal'>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot='dialog-content'
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className='sr-only'>Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='dialog-header'
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='dialog-footer'
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot='dialog-title'
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot='dialog-description'
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
};
|
||||
132
src/ASPBaseOIDC.Web.Ui/src/components/ui/drawer.tsx
Normal file
132
src/ASPBaseOIDC.Web.Ui/src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot='drawer' {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot='drawer-trigger' {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot='drawer-portal' {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot='drawer-close' {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot='drawer-overlay'
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot='drawer-portal'>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot='drawer-content'
|
||||
className={cn(
|
||||
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
||||
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
||||
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
||||
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block' />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='drawer-header'
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='drawer-footer'
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot='drawer-title'
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot='drawer-description'
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription
|
||||
};
|
||||
257
src/ASPBaseOIDC.Web.Ui/src/components/ui/dropdown-menu.tsx
Normal file
257
src/ASPBaseOIDC.Web.Ui/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot='dropdown-menu-trigger'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot='dropdown-menu-content'
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot='dropdown-menu-item'
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot='dropdown-menu-checkbox-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className='size-4' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot='dropdown-menu-radio-group'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot='dropdown-menu-radio-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className='size-2 fill-current' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot='dropdown-menu-label'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot='dropdown-menu-separator'
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot='dropdown-menu-shortcut'
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot='dropdown-menu-sub-trigger'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className='ml-auto size-4' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent
|
||||
};
|
||||
187
src/ASPBaseOIDC.Web.Ui/src/components/ui/form.tsx
Normal file
187
src/ASPBaseOIDC.Web.Ui/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
UseFormReturn,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues
|
||||
} from 'react-hook-form';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const Form = ({
|
||||
children,
|
||||
onSubmit,
|
||||
form,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSubmit: (data: any) => void;
|
||||
form: UseFormReturn<any, any, undefined>;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit} className={className}>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot='form-item'
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot='form-label'
|
||||
data-error={!!error}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot='form-control'
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot='form-description'
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? '') : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot='form-message'
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField
|
||||
};
|
||||
13
src/ASPBaseOIDC.Web.Ui/src/components/ui/heading.tsx
Normal file
13
src/ASPBaseOIDC.Web.Ui/src/components/ui/heading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
interface HeadingProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const Heading: React.FC<HeadingProps> = ({ title, description }) => {
|
||||
return (
|
||||
<div>
|
||||
<h2 className='text-3xl font-bold tracking-tight'>{title}</h2>
|
||||
<p className='text-muted-foreground text-sm'>{description}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
src/ASPBaseOIDC.Web.Ui/src/components/ui/hover-card.tsx
Normal file
44
src/ASPBaseOIDC.Web.Ui/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot='hover-card' {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot='hover-card-trigger' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot='hover-card-portal'>
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot='hover-card-content'
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
77
src/ASPBaseOIDC.Web.Ui/src/components/ui/input-otp.tsx
Normal file
77
src/ASPBaseOIDC.Web.Ui/src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { MinusIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='input-otp-group'
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
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]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
|
||||
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot='input-otp-separator' role='separator' {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
21
src/ASPBaseOIDC.Web.Ui/src/components/ui/input.tsx
Normal file
21
src/ASPBaseOIDC.Web.Ui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
24
src/ASPBaseOIDC.Web.Ui/src/components/ui/label.tsx
Normal file
24
src/ASPBaseOIDC.Web.Ui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
276
src/ASPBaseOIDC.Web.Ui/src/components/ui/menubar.tsx
Normal file
276
src/ASPBaseOIDC.Web.Ui/src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as MenubarPrimitive from '@radix-ui/react-menubar';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot='menubar'
|
||||
className={cn(
|
||||
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot='menubar-group' {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />;
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot='menubar-trigger'
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = 'start',
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot='menubar-content'
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot='menubar-item'
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot='menubar-checkbox-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className='size-4' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot='menubar-radio-item'
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className='size-2 fill-current' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot='menubar-label'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot='menubar-separator'
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot='menubar-shortcut'
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot='menubar-sub-trigger'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className='ml-auto h-4 w-4' />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot='menubar-sub-content'
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user