I am batman
This commit is contained in:
11
.eslintrc.json
Normal file
11
.eslintrc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"import/no-unresolved": "off",
|
||||
"import/named": "off",
|
||||
"no-console": "warn",
|
||||
"react-hooks/exhaustive-deps": "warn"
|
||||
}
|
||||
}
|
||||
5
.github/instructions/00-main.md
vendored
Normal file
5
.github/instructions/00-main.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Frontend Instruction Entry Point (Do Not Edit)
|
||||
|
||||
1. Read the user's prompt input completely before taking action.
|
||||
2. Act as a frontend engineer for this repository's architecture and UI patterns.
|
||||
3. Update only the scoped instruction files in this directory when requested; do not modify this file.
|
||||
15
.github/instructions/api.md
vendored
Normal file
15
.github/instructions/api.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Frontend API Instructions
|
||||
|
||||
## Scope
|
||||
- Applies to API calls in `src/lib/api/api.ts` and feature-level data hooks.
|
||||
|
||||
## Rules
|
||||
- Keep axios-based API calls centralized in `src/lib/api/api.ts`.
|
||||
- Map backend response shapes to frontend-friendly models in hooks or API mappers.
|
||||
- Follow existing tickets pattern: `api.ts` + feature hook (`useX`) + presentational components.
|
||||
- Use optimistic updates carefully; ensure API failures do not leave inconsistent UI state.
|
||||
- Keep request payload keys aligned with backend contract (`section_id`, `position`, etc.).
|
||||
|
||||
## Error Handling
|
||||
- Surface recoverable errors to the user with clear toast messages.
|
||||
- Avoid silent failures for create/update/delete flows.
|
||||
14
.github/instructions/containerization.md
vendored
Normal file
14
.github/instructions/containerization.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Frontend Containerization Instructions
|
||||
|
||||
## Scope
|
||||
- Applies to frontend Docker/build/runtime concerns for Next.js.
|
||||
|
||||
## Rules
|
||||
- Keep `.next` as a generated artifact; never rely on stale local cache in source control.
|
||||
- If dev manifest/cache issues appear, clear `.next` and restart dev server.
|
||||
- Keep environment-driven API base URL behavior explicit and documented.
|
||||
- Ensure Docker/compose settings match the expected runtime port and host bindings.
|
||||
|
||||
## Build Health
|
||||
- Distinguish pre-existing lint/type issues from feature-specific regressions in reports.
|
||||
- Do not weaken lint/type checks just to pass builds.
|
||||
15
.github/instructions/state-and-dnd.md
vendored
Normal file
15
.github/instructions/state-and-dnd.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# State and DnD Instructions
|
||||
|
||||
## Scope
|
||||
- Applies to stateful feature logic and drag/drop interactions (e.g., Kanban with dnd-kit).
|
||||
|
||||
## Rules
|
||||
- Keep business/data mutations in hooks; keep components mostly presentational.
|
||||
- Persist ordering changes deterministically using explicit `position` updates.
|
||||
- Avoid nested competing scroll containers when horizontal drag/drop is involved.
|
||||
- Ensure drag overlays and scroll behavior work for mouse and touch sensors.
|
||||
- When replacing local persistence with API-backed state, remove stale store dependencies cleanly.
|
||||
|
||||
## Reliability
|
||||
- Debounce or batch expensive reorder API calls where appropriate.
|
||||
- After structural changes (move/delete section), refresh canonical state from API when needed.
|
||||
15
.github/instructions/ui.md
vendored
Normal file
15
.github/instructions/ui.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# UI Instructions
|
||||
|
||||
## Scope
|
||||
- Applies to page/component work under `src/app`, `src/components`, and `src/features`.
|
||||
|
||||
## Rules
|
||||
- Preserve existing design language and component primitives from `src/components/ui`.
|
||||
- Keep layouts responsive with explicit overflow control for dense views (e.g., kanban horizontal overflow).
|
||||
- Prefer composition over one-off custom widgets; reuse dialog, card, table, and form primitives.
|
||||
- Keep interaction states clear (loading, empty, error) for data-driven pages.
|
||||
- Avoid visual regressions in header/sidebar containment when introducing horizontal scroll regions.
|
||||
|
||||
## Accessibility
|
||||
- Maintain keyboard access for dialogs, menus, and drag handles.
|
||||
- Keep meaningful `sr-only` labels for icon-only controls.
|
||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*
|
||||
.env*.local
|
||||
gitmvp.md
|
||||
backend_documentation.md
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.idea/
|
||||
# clerk configuration (can include secrets)
|
||||
/.clerk/
|
||||
|
||||
|
||||
# cursor
|
||||
.cursorrules
|
||||
/memory-bank
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
1
.husky/pre-push
Normal file
1
.husky/pre-push
Normal file
@@ -0,0 +1 @@
|
||||
pnpm run build
|
||||
4
.npmrc
Normal file
4
.npmrc
Normal file
@@ -0,0 +1,4 @@
|
||||
legacy-peer-deps=true
|
||||
shamefully-hoist=true
|
||||
# added this to fix sentry warning
|
||||
# https://docs.sentry.io/platforms/javascript/troubleshooting/#pnpm-resolving-import-in-the-middle-external-package-errors
|
||||
48
.prettierignore
Normal file
48
.prettierignore
Normal file
@@ -0,0 +1,48 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
.next
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# changelog
|
||||
CHANGELOG.md
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Other common ignores
|
||||
node_modules
|
||||
.next
|
||||
build
|
||||
dist
|
||||
ico
|
||||
12
.prettierrc
Normal file
12
.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"semi": true,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"jsxSingleQuote": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
31
.vscode/launch.json
vendored
Normal file
31
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
},
|
||||
"env": {
|
||||
"NEXT_PUBLIC_SENTRY_DISABLED": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
21
LICENSE
Normal file
21
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
README.md
Normal file
115
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
components.json
Normal file
16
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
env.example.txt
Normal file
65
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
|
||||
62
next.config.ts
Normal file
62
next.config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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: ''
|
||||
}
|
||||
],
|
||||
domains: [
|
||||
'cdn.jsdelivr.net', // <-- add this line
|
||||
'avatars.githubusercontent.com', // <-- add this line
|
||||
// add other domains if needed
|
||||
],
|
||||
},
|
||||
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;
|
||||
12477
package-lock.json
generated
Normal file
12477
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
120
package.json
Normal file
120
package.json
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"name": "OpsMastery.v5-Client",
|
||||
"version": "1.0.0",
|
||||
"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": {
|
||||
"@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": "^3.9.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",
|
||||
"axios": "^1.11.0",
|
||||
"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",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"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-icons": "^5.5.0",
|
||||
"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": "^3.24.1",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
9098
pnpm-lock.yaml
generated
Normal file
9098
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
1
public/assets/sentry.svg
Normal file
1
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
public/next.svg
Normal file
1
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
public/vercel.svg
Normal file
1
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 |
5
src/app/auth/reset-password/page.tsx
Normal file
5
src/app/auth/reset-password/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ResetPasswordView from '@/features/auth/components/reset-password-view';
|
||||
|
||||
export default function Page() {
|
||||
return <ResetPasswordView />;
|
||||
}
|
||||
27
src/app/auth/sign-in/[[...sign-in]]/page.tsx
Normal file
27
src/app/auth/sign-in/[[...sign-in]]/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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) {
|
||||
}
|
||||
return <SignInViewPage stars={stars} />;
|
||||
}
|
||||
27
src/app/auth/sign-up/[[...sign-up]]/page.tsx
Normal file
27
src/app/auth/sign-up/[[...sign-up]]/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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) {
|
||||
}
|
||||
return <SignUpViewPage stars={stars} />;
|
||||
}
|
||||
22
src/app/auth/verification-failed/page.tsx
Normal file
22
src/app/auth/verification-failed/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function VerificationFailedPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Verification Failed</CardTitle>
|
||||
<CardDescription>We could not verify your email. The link may be invalid or expired.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<span className="text-red-500 text-2xl font-bold">❌ Verification Failed</span>
|
||||
<Button asChild variant="outline" className="w-full mt-2">
|
||||
<Link href="/auth/sign-up">Try Again</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/app/auth/verification-success/page.tsx
Normal file
22
src/app/auth/verification-success/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function VerificationSuccessPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Verified</CardTitle>
|
||||
<CardDescription>Your email has been successfully verified. You can now sign in.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<span className="text-green-600 text-2xl font-bold">✅ Verified</span>
|
||||
<Button asChild className="w-full mt-2">
|
||||
<Link href="/auth/sign-in">Go to Sign In</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/app/dashboard/calendar/page.tsx
Normal file
52
src/app/dashboard/calendar/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import PageContainer from '@/components/layout/page-container'
|
||||
import CalendarSidebar from '@/features/calendar/components/CalendarSidebar'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
type CalendarEvent = {
|
||||
id: string
|
||||
title: string
|
||||
date: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const dummyEvents: CalendarEvent[] = [
|
||||
{ id: '1', title: 'Team Meeting', date: '2025-09-05', description: 'Discuss project updates' },
|
||||
{ id: '2', title: 'Release Day', date: '2025-09-10', description: 'OpsMastery v5 launch' },
|
||||
{ id: '3', title: '1:1 with Cody', date: '2025-09-12' },
|
||||
{ id: '4', title: 'Sprint Planning', date: '2025-09-15', description: 'Plan next sprint tasks' },
|
||||
{ id: '5', title: 'Demo Day', date: '2025-09-20', description: 'Showcase new features' },
|
||||
]
|
||||
|
||||
const Page = () => {
|
||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
|
||||
|
||||
const handleSelectEvent = (event: CalendarEvent) => {
|
||||
setSelectedEvent(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer scrollable>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Calendar Sidebar */}
|
||||
<CalendarSidebar events={dummyEvents} onSelectEvent={handleSelectEvent} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{selectedEvent ? (
|
||||
<div className="p-4">
|
||||
<h2 className="font-semibold">{selectedEvent.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{selectedEvent.date}</p>
|
||||
{selectedEvent.description && <p>{selectedEvent.description}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-muted-foreground">Select an event to see details.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
18
src/app/dashboard/chat/page.tsx
Normal file
18
src/app/dashboard/chat/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import ChatContainer from "@/features/chat/components/ChatContainer";
|
||||
|
||||
export default function Home() {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [selectedChatUser, setSelectedChatUser] = useState({
|
||||
id: "1",
|
||||
username: "Alice",
|
||||
email: "alice@example.com",
|
||||
avatar: "",
|
||||
});
|
||||
|
||||
return (
|
||||
<ChatContainer />
|
||||
);
|
||||
}
|
||||
9
src/app/dashboard/kanban/page.tsx
Normal file
9
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 />;
|
||||
}
|
||||
32
src/app/dashboard/layout.tsx
Normal file
32
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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 />
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</KBar>
|
||||
);
|
||||
}
|
||||
16
src/app/dashboard/overview/@area_stats/error.tsx
Normal file
16
src/app/dashboard/overview/@area_stats/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 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>
|
||||
);
|
||||
}
|
||||
5
src/app/dashboard/overview/@area_stats/loading.tsx
Normal file
5
src/app/dashboard/overview/@area_stats/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <AreaGraphSkeleton />;
|
||||
}
|
||||
7
src/app/dashboard/overview/@area_stats/page.tsx
Normal file
7
src/app/dashboard/overview/@area_stats/page.tsx
Normal file
@@ -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 />;
|
||||
}
|
||||
60
src/app/dashboard/overview/@bar_stats/error.tsx
Normal file
60
src/app/dashboard/overview/@bar_stats/error.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
5
src/app/dashboard/overview/@bar_stats/loading.tsx
Normal file
5
src/app/dashboard/overview/@bar_stats/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BarGraphSkeleton } from '@/features/overview/components/bar-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <BarGraphSkeleton />;
|
||||
}
|
||||
8
src/app/dashboard/overview/@bar_stats/page.tsx
Normal file
8
src/app/dashboard/overview/@bar_stats/page.tsx
Normal file
@@ -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 />;
|
||||
}
|
||||
16
src/app/dashboard/overview/@pie_stats/error.tsx
Normal file
16
src/app/dashboard/overview/@pie_stats/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 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>
|
||||
);
|
||||
}
|
||||
5
src/app/dashboard/overview/@pie_stats/loading.tsx
Normal file
5
src/app/dashboard/overview/@pie_stats/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PieGraphSkeleton } from '@/features/overview/components/pie-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <PieGraphSkeleton />;
|
||||
}
|
||||
7
src/app/dashboard/overview/@pie_stats/page.tsx
Normal file
7
src/app/dashboard/overview/@pie_stats/page.tsx
Normal file
@@ -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 />;
|
||||
}
|
||||
16
src/app/dashboard/overview/@sales/error.tsx
Normal file
16
src/app/dashboard/overview/@sales/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 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>
|
||||
);
|
||||
}
|
||||
6
src/app/dashboard/overview/@sales/loading.tsx
Normal file
6
src/app/dashboard/overview/@sales/loading.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { RecentSalesSkeleton } from '@/features/overview/components/recent-sales-skeleton';
|
||||
import React from 'react';
|
||||
|
||||
export default function Loading() {
|
||||
return <RecentSalesSkeleton />;
|
||||
}
|
||||
7
src/app/dashboard/overview/@sales/page.tsx
Normal file
7
src/app/dashboard/overview/@sales/page.tsx
Normal file
@@ -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/app/dashboard/overview/error.tsx
Normal file
16
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>
|
||||
);
|
||||
}
|
||||
131
src/app/dashboard/overview/layout.tsx
Normal file
131
src/app/dashboard/overview/layout.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
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='*: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}
|
||||
</div>
|
||||
<div className='col-span-4'>{area_stats}</div>
|
||||
<div className='col-span-4 md:col-span-3'>{pie_stats}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
9
src/app/dashboard/overview/page.tsx
Normal file
9
src/app/dashboard/overview/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export default function DashboardOverview() {
|
||||
return (
|
||||
<div>
|
||||
Welcome to your dashboard!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/dashboard/page.tsx
Normal file
10
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
{/* Main dashboard UI here */}
|
||||
Welcome to your dashboard!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/app/dashboard/product/[productId]/page.tsx
Normal file
23
src/app/dashboard/product/[productId]/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
54
src/app/dashboard/product/page.tsx
Normal file
54
src/app/dashboard/product/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
|
||||
searchParamsCache.parse(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>
|
||||
);
|
||||
}
|
||||
9
src/app/dashboard/profile/[[...profile]]/page.tsx
Normal file
9
src/app/dashboard/profile/[[...profile]]/page.tsx
Normal file
@@ -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 />;
|
||||
}
|
||||
23
src/app/dashboard/tickets/[ticketId]/page.tsx
Normal file
23
src/app/dashboard/tickets/[ticketId]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import FormCardSkeleton from '@/components/form-card-skeleton';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Suspense } from 'react';
|
||||
import TicketViewPage from '@/features/tickets/components/ticket-data-table';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard : Ticket View'
|
||||
};
|
||||
|
||||
type PageProps = { params: Promise<{ ticketId: 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 />}>
|
||||
<TicketViewPage ticketId={params.ticketId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
20
src/app/dashboard/tickets/new/page.tsx
Normal file
20
src/app/dashboard/tickets/new/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import PageContainer from "@/components/layout/page-container";
|
||||
import { Heading } from "@/components/ui/heading";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import NewTicketForm from "@/features/tickets/components/new-ticket-form";
|
||||
|
||||
export const metadata = {
|
||||
title: "Dashboard: New Ticket",
|
||||
};
|
||||
|
||||
export default function NewTicketPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col gap-4 max-w-2xl mx-auto">
|
||||
<Heading title="Create Ticket" description="Add a new support ticket" />
|
||||
<Separator />
|
||||
<NewTicketForm />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
44
src/app/dashboard/tickets/page.tsx
Normal file
44
src/app/dashboard/tickets/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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 TicketDataTable from "@/features/tickets/components/ticket-data-table"
|
||||
import { searchParamsCache } 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: Tickets",
|
||||
}
|
||||
|
||||
type pageProps = {
|
||||
searchParams: Promise<SearchParams>
|
||||
}
|
||||
|
||||
export default async function Page(props: pageProps) {
|
||||
const searchParams = await props.searchParams
|
||||
searchParamsCache.parse(searchParams)
|
||||
|
||||
return (
|
||||
<PageContainer scrollable={false}>
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title="Tickets" description="Manage tickets" />
|
||||
<Link href="/dashboard/tickets/new" className={cn(buttonVariants(), "text-xs md:text-sm")}>
|
||||
<IconPlus className="mr-2 h-4 w-4" /> Add New
|
||||
</Link>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex-1 w-full">
|
||||
<Suspense fallback={<DataTableSkeleton columnCount={8} rowCount={8} filterCount={2} />}>
|
||||
<TicketDataTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
23
src/app/dashboard/users/[userId]/page.tsx
Normal file
23
src/app/dashboard/users/[userId]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import FormCardSkeleton from '@/components/form-card-skeleton';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Suspense } from 'react';
|
||||
// import UserViewPage from '@/features/users/components/user-view-page';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard : User View'
|
||||
};
|
||||
|
||||
type PageProps = { params: Promise<{ userId: 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 />}>
|
||||
{/* <UserViewPage userId={params.userId} /> */}
|
||||
</Suspense>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
44
src/app/dashboard/users/page.tsx
Normal file
44
src/app/dashboard/users/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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 UsersDataTable from "@/features/users/components/user-data-table"
|
||||
import { searchParamsCache } 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: Users",
|
||||
}
|
||||
|
||||
type pageProps = {
|
||||
searchParams: Promise<SearchParams>
|
||||
}
|
||||
|
||||
export default async function Page(props: pageProps) {
|
||||
const searchParams = await props.searchParams
|
||||
searchParamsCache.parse(searchParams)
|
||||
|
||||
return (
|
||||
<PageContainer scrollable={false}>
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title="Users" description="Manage users" />
|
||||
<Link href="/dashboard/users/new" className={cn(buttonVariants(), "text-xs md:text-sm")}>
|
||||
<IconPlus className="mr-2 h-4 w-4" /> Add New
|
||||
</Link>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex-1 w-full overflow-auto">
|
||||
<Suspense fallback={<DataTableSkeleton columnCount={8} rowCount={8} filterCount={2} />}>
|
||||
<UsersDataTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
27
src/app/global-error.tsx
Normal file
27
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/app/globals.css
Normal file
158
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;
|
||||
}
|
||||
82
src/app/layout.tsx
Normal file
82
src/app/layout.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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 { ActiveThemeProvider } from '@/components/active-theme'; // <-- Add this import
|
||||
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 showSpinner={false} />
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
>
|
||||
{/* Add ActiveThemeProvider here */}
|
||||
<ActiveThemeProvider initialTheme={activeThemeValue}>
|
||||
<Providers>
|
||||
<Toaster />
|
||||
{/* <TokenRefresh /> */}
|
||||
{children}
|
||||
</Providers>
|
||||
</ActiveThemeProvider>
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
36
src/app/not-found.tsx
Normal file
36
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>
|
||||
);
|
||||
}
|
||||
13
src/app/page.tsx
Normal file
13
src/app/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Dashboard() {
|
||||
const cookieStore = await cookies();
|
||||
const accessToken = cookieStore.get('access_token')?.value;
|
||||
|
||||
if (!accessToken) {
|
||||
redirect('/auth/sign-in');
|
||||
} else {
|
||||
redirect('/dashboard/overview');
|
||||
}
|
||||
}
|
||||
149
src/app/theme.css
Normal file
149
src/app/theme.css
Normal file
@@ -0,0 +1,149 @@
|
||||
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-pink,
|
||||
.theme-pink-scaled {
|
||||
--primary: var(--color-pink-600);
|
||||
--primary-foreground: var(--color-pink-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-pink-500);
|
||||
--primary-foreground: var(--color-pink-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-red,
|
||||
.theme-red-scaled {
|
||||
--primary: var(--color-red-600);
|
||||
--primary-foreground: var(--color-red-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-red-500);
|
||||
--primary-foreground: var(--color-red-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-purple,
|
||||
.theme-purple-scaled {
|
||||
--primary: var(--color-purple-600);
|
||||
--primary-foreground: var(--color-purple-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-purple-500);
|
||||
--primary-foreground: var(--color-purple-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;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-gold,
|
||||
.theme-gold-scaled {
|
||||
--primary: var(--color-amber-500, #FFD700);
|
||||
--primary-foreground: var(--color-amber-50, #fff8e1);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-amber-400, #FFD700);
|
||||
--primary-foreground: var(--color-amber-50, #fff8e1);
|
||||
}
|
||||
}
|
||||
15
src/app/video-chat-room/page.tsx
Normal file
15
src/app/video-chat-room/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import AppSidebar from '@/components/layout/app-sidebar';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import VideoChat from '../../features/video-chat-room/components/video-chat-room';
|
||||
|
||||
export default function VideoChatRoomPage() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<VideoChat />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
67
src/components/active-theme.tsx
Normal file
67
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/components/breadcrumbs.tsx
Normal file
41
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>
|
||||
);
|
||||
}
|
||||
316
src/components/file-uploader.tsx
Normal file
316
src/components/file-uploader.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
'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';
|
||||
|
||||
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/components/form-card-skeleton.tsx
Normal file
52
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>
|
||||
);
|
||||
}
|
||||
78
src/components/icons.tsx
Normal file
78
src/components/icons.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconCalendar,
|
||||
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,
|
||||
IconMessageCircle,
|
||||
IconUsers,
|
||||
IconTicket
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export type Icon = React.ComponentType<IconProps>;
|
||||
|
||||
export const Icons = {
|
||||
dashboard: IconLayoutDashboard,
|
||||
logo: IconCommand,
|
||||
login: IconLogin,
|
||||
calendar: IconCalendar,
|
||||
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,
|
||||
message: IconMessageCircle,
|
||||
users: IconUsers,
|
||||
ticket: IconTicket
|
||||
};
|
||||
83
src/components/kbar/index.tsx
Normal file
83
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/components/kbar/render-result.tsx
Normal file
25
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/components/kbar/result-item.tsx
Normal file
77
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;
|
||||
36
src/components/kbar/use-theme-switching.tsx
Normal file
36
src/components/kbar/use-theme-switching.tsx
Normal file
@@ -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;
|
||||
13
src/components/layout/ThemeToggle/theme-provider.tsx
Normal file
13
src/components/layout/ThemeToggle/theme-provider.tsx
Normal file
@@ -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>;
|
||||
}
|
||||
46
src/components/layout/ThemeToggle/theme-toggle.tsx
Normal file
46
src/components/layout/ThemeToggle/theme-toggle.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
239
src/components/layout/app-sidebar.tsx
Normal file
239
src/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
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 {
|
||||
IconBell,
|
||||
IconChevronRight,
|
||||
IconChevronsDown,
|
||||
IconCreditCard,
|
||||
IconLogout,
|
||||
IconPhotoUp,
|
||||
IconUserCircle,
|
||||
IconMessage
|
||||
} from '@tabler/icons-react';
|
||||
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';
|
||||
import { useAuth } from "@/hooks/useAuth"; // <-- Use new hook
|
||||
|
||||
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' }
|
||||
];
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
export default function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isOpen } = useMediaQuery();
|
||||
const router = useRouter();
|
||||
const { user, signOut } = useAuth(); // <-- Use new hook
|
||||
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// ...your sidebar logic...
|
||||
}, [isOpen]);
|
||||
|
||||
if (!hasMounted) return null;
|
||||
|
||||
const handleSwitchTenant = (_tenantId: string) => {
|
||||
// Tenant switching functionality would be implemented here
|
||||
};
|
||||
|
||||
const activeTenant = tenants[0];
|
||||
|
||||
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_id: user.id,
|
||||
fullName: user.name || user.username || "",
|
||||
emailAddresses: [{ emailAddress: user.email }],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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_id: user.id,
|
||||
fullName: user.name || user.username || "",
|
||||
emailAddresses: [{ emailAddress: user.email }],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
router.push('/auth/sign-in');
|
||||
}}
|
||||
>
|
||||
<IconLogout className='mr-2 h-4 w-4' />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
18
src/components/layout/cta-github.tsx
Normal file
18
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/components/layout/header.tsx
Normal file
31
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>
|
||||
);
|
||||
}
|
||||
22
src/components/layout/page-container.tsx
Normal file
22
src/components/layout/page-container.tsx
Normal file
@@ -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 w-full min-w-0 flex-1 p-4 md:px-6'>{children}</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className='flex w-full min-w-0 flex-1 p-4 md:px-6'>{children}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/components/layout/providers.tsx
Normal file
16
src/components/layout/providers.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
import { useTheme } from 'next-themes';
|
||||
import React from 'react';
|
||||
|
||||
// You can create your own provider or just use React's context/provider as needed
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
// Example: useTheme for dark/light mode
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Add your own context providers here if needed
|
||||
return (
|
||||
<React.Fragment>
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
96
src/components/layout/user-nav.tsx
Normal file
96
src/components/layout/user-nav.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { useRouter } from 'next/navigation';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
export function UserNav() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<{
|
||||
user_id: string | number;
|
||||
fullName: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = localStorage.getItem('user_id');
|
||||
if (!userId) return;
|
||||
fetch(`${API_URL}/users/${userId}`, { credentials: 'include' })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setUser({
|
||||
user_id: data.user_id || userId,
|
||||
fullName: data.name || data.username || '',
|
||||
email: data.email,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
|
||||
<UserAvatarProfile
|
||||
user={{
|
||||
user_id: user.user_id,
|
||||
fullName: user.fullName,
|
||||
emailAddresses: [{ emailAddress: user.email }],
|
||||
}}
|
||||
/>
|
||||
</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.email}
|
||||
</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
|
||||
onClick={async () => {
|
||||
await fetch(`${API_URL}/signout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
router.push('/auth/sign-in');
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
46
src/components/modal/alert-modal.tsx
Normal file
46
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>
|
||||
);
|
||||
};
|
||||
85
src/components/nav-main.tsx
Normal file
85
src/components/nav-main.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'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>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Video Chat" 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">
|
||||
<a href="/video-chat-room">Video Chat</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{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/components/nav-projects.tsx
Normal file
89
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>
|
||||
);
|
||||
}
|
||||
146
src/components/nav-user.tsx
Normal file
146
src/components/nav-user.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
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';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar();
|
||||
const [user, setUser] = useState<{
|
||||
username: string;
|
||||
email: string;
|
||||
user_id?: string | number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = localStorage.getItem('user_id');
|
||||
if (!userId) return;
|
||||
fetch(`${API_URL}/users/${userId}`, { credentials: 'include' })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setUser({
|
||||
user_id: data.user_id || userId,
|
||||
username: data.username || '',
|
||||
email: data.email || '',
|
||||
});
|
||||
if (data.username) {
|
||||
localStorage.setItem('username', data.username);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const profilePhotoUrl = user.user_id
|
||||
? `${API_URL}/users/${user.user_id}/profile_photo?${Date.now()}`
|
||||
: '';
|
||||
|
||||
const fallbackInitials =
|
||||
user.username?.slice(0, 2).toUpperCase() ||
|
||||
user.email?.slice(0, 2).toUpperCase() ||
|
||||
'U';
|
||||
|
||||
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={profilePhotoUrl} alt={user.username} crossOrigin="use-credentials" />
|
||||
<AvatarFallback className='rounded-lg'>{fallbackInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.username}</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={profilePhotoUrl} alt={user.username} crossOrigin="use-credentials" />
|
||||
<AvatarFallback className='rounded-lg'>{fallbackInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.username}</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
|
||||
onClick={async () => {
|
||||
await fetch(`${API_URL}/signout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
window.location.href = '/auth/sign-in';
|
||||
}}
|
||||
>
|
||||
<IconLogout className='mr-2 h-4 w-4' />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
85
src/components/org-switcher.tsx
Normal file
85
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/components/search-input.tsx
Normal file
23
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>
|
||||
);
|
||||
}
|
||||
94
src/components/theme-selector.tsx
Normal file
94
src/components/theme-selector.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'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' },
|
||||
{ name: 'Pink', value: 'pink' },
|
||||
{ name: 'Red', value: 'red' },
|
||||
{ name: 'Purple', value: 'purple' },
|
||||
{ name: 'Gold', value: 'gold' } // <-- Added gold
|
||||
];
|
||||
|
||||
const SCALED_THEMES = [
|
||||
{ name: 'Default', value: 'default-scaled' },
|
||||
{ name: 'Blue', value: 'blue-scaled' },
|
||||
{ name: 'Green', value: 'green-scaled' },
|
||||
{ name: 'Amber', value: 'amber-scaled' },
|
||||
{ name: 'Pink', value: 'pink-scaled' },
|
||||
{ name: 'Red', value: 'red-scaled' },
|
||||
{ name: 'Purple', value: 'purple-scaled' },
|
||||
{ name: 'Gold', value: 'gold-scaled' } // <-- Added gold
|
||||
];
|
||||
|
||||
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/components/ui/accordion.tsx
Normal file
66
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/components/ui/alert-dialog.tsx
Normal file
157
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/components/ui/alert.tsx
Normal file
66
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/components/ui/aspect-ratio.tsx
Normal file
11
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/components/ui/avatar.tsx
Normal file
53
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/components/ui/badge.tsx
Normal file
46
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/components/ui/breadcrumb.tsx
Normal file
109
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/components/ui/button.tsx
Normal file
59
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/components/ui/calendar.tsx
Normal file
76
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/components/ui/card.tsx
Normal file
92
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
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user