first init
This commit is contained in:
commit
cc403fc5ec
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: #[kiranism]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: kir4n
|
||||
custom: ['https://www.paypal.me/imkir4n'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
@ -0,0 +1,45 @@
|
|||
# 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
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.idea/
|
||||
|
||||
# cursor
|
||||
.cursorrules
|
||||
/memory-bank
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
|
@ -0,0 +1 @@
|
|||
npx lint-staged
|
|
@ -0,0 +1 @@
|
|||
pnpm run build
|
|
@ -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
|
|
@ -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
|
|
@ -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"]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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.
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
# =================================================================
|
||||
# Authentication Configuration (Firebase)
|
||||
# =================================================================
|
||||
# Firebase configuration for authentication
|
||||
# Get these values from your Firebase project console
|
||||
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
|
||||
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID=
|
||||
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=
|
||||
|
||||
|
||||
# =================================================================
|
||||
# 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
|
|
@ -0,0 +1,178 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "32327803117",
|
||||
"project_id": "ta-dentakoas-73626",
|
||||
"storage_bucket": "ta-dentakoas-73626.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:32327803117:android:c3d28dbe4aa4a4bbbe6ce4",
|
||||
"android_client_info": {
|
||||
"package_name": "com.dentakoas.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "32327803117-anqq7kasfpgkq8mptuf2vo3pn9autlgg.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.dentakoas.app",
|
||||
"certificate_hash": "9756b541f9a99c507bc1dff5af47f98a349651ba"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDt4x8WK99EBw1T4_lQBsCVtYJko5_oIFA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "32327803117-v1smdefgi2qdneobft1acnjelk1v5l6u.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.dentaKoas"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:32327803117:android:f93b17d6cc3486dabe6ce4",
|
||||
"android_client_info": {
|
||||
"package_name": "com.dentakoas.com"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "32327803117-7ke405dr33senpusjo9h2iidhcfhnqv8.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.dentakoas.com",
|
||||
"certificate_hash": "9756b541f9a99c507bc1dff5af47f98a349651ba"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDt4x8WK99EBw1T4_lQBsCVtYJko5_oIFA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "32327803117-v1smdefgi2qdneobft1acnjelk1v5l6u.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.dentaKoas"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:32327803117:android:ef30747f79a105c9be6ce4",
|
||||
"android_client_info": {
|
||||
"package_name": "com.example.denta_koas"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "32327803117-3v517dooucfeqog94f1hfmfst3k96g78.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.example.denta_koas",
|
||||
"certificate_hash": "9756b541f9a99c507bc1dff5af47f98a349651ba"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDt4x8WK99EBw1T4_lQBsCVtYJko5_oIFA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "32327803117-v1smdefgi2qdneobft1acnjelk1v5l6u.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.dentaKoas"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:32327803117:android:a255675e744c553dbe6ce4",
|
||||
"android_client_info": {
|
||||
"package_name": "com.putricantik.denta_koas"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDt4x8WK99EBw1T4_lQBsCVtYJko5_oIFA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "32327803117-v1smdefgi2qdneobft1acnjelk1v5l6u.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.dentaKoas"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"type": "service_account",
|
||||
"project_id": "ta-dentakoas-73626",
|
||||
"private_key_id": "3756bd3a1dfc9b214a3ef123727f41c7b824dd8f",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCThot63+0JRbMO\nELWR9BayeH2nY33UvRZHQ6YY9p1nzWL/3UrKhkjGyRoMgYwp+kBeLcI+ZuIn+BSu\n9Hb0HZ0VEqHEeRq/4iQryaFn0t8VjqKBKKwJDJ1Ef0rNSxb21ZzdfZM4Nqfj1q//\nKlRSqNWJRAxW6v0TCm9PkSM3VTEYRZXPs+FUIvFntGsvu1BjYyiKNCEbgvsylBcK\nLGrYiPgxrScjzQyaQkw7ONQJr/lxYRRsmuowmTkQUQwpXB6Pp8nKCQ07LZsUMGnK\nBtFg41vBULACdjCH7A02EJy2mNWxiTBzk+5ShlQHW7LRikI/exRL16aThrrAUGWa\ns4BoTHYbAgMBAAECggEAAKDxJ4LBEbMwe3Al7xSUyC41RSkzhe61fyrSNNArqY1w\nX5DDtmaphrvphFz70zcdFa47YhvWayzmeIWKdxb/gM0Oz7Ws+102WNH+uM5nGmbX\n6978W44WEFfzGTpL+lPhSWV+ULBMFN0yEM9POhyqXW/QqIEF2+2gvc7G9cj6J951\n0WFj/imjWAAiKXMdvQV+27sKCZI+VTGaiaZD/Nrj95sM4vre5Ood0POzOQji12CL\nN7ZBkrvXHeOY3uokkPeFXf2nVLU0w4wOYTsangYeDKFqLkLC1hDawjeEb6TpB3on\nmkmfwzEBTlaB7QPdyYA4ypwPBk2E3pNy+mg1BByi8QKBgQDCgpVR8IeJJqmIaun0\ntBf6MIb/z+gxuAcXnaJXJzUoJwaWCI5VImSNjVLUUeYs5uMMyzUQ/dmfFCqaQGuw\nlWGzO/8U+Y2VzSip1s11jypgRt/EdO6wZacmhNLE3ZuF7BEzckyDsVq8ZrC7X9pu\nyLFzwY82ZxJHWySdgoYtmFIfkwKBgQDCKZKhFWCsBc9rErzjnJ2oDdJFAgRq7XLs\nd3UQs4i/N/El1WlCNM/Zdedo+t0pZwuxO7350Cjjbpj7MkGzBEbUWcW2trj96Kbq\noypO3olht23zUG5FGtOpmx7ddhHG1tjQu4nkL5dMR+0dVRGxeWX4j0PtroKQ5uif\nPXU3zpgUWQKBgQCEdZTmRhymo7OT+Or1/6bMdDua/aSsJxx9UpX5L/QdMeZkSdNT\n2qYLV5Buko9jSKCaV8/9kEGg+YTBotkzWac8/cCKMh/fQeVNM8CbLDG5espr6Q2b\nBr67lnxKIHgGzWoxVMcSb7bQ0kXMdEw2YWmm4Mt8StjByym2MeraJf7XDQKBgEjm\nDvWH7znnHI9ZA655uP/F4LKkJUB4UnPSDhPtNGSG/nV0AQcoiPleuTnDJpJK7Crp\nAVTwAMoTVkDKB7zM2EMWSZNmW2KJnVHvXQ0rpG0Zz6BRDHXJsrq1UQtZLyd2twT3\nxLf13lp3juE6dWAq8kw2jgyJPuBkseZHzuDbImJRAoGAUM2tkFOX3XTUD+B38vFI\nHVNA12ekCJOYi9L+/MaAzot2juVxGJuGs8tJVmi0ibJTjaEw/jGEpjzyhrkR6WwT\nMXBsJ/nsqr0LoFmr/pzDnBB7/oKm1eZuQZQcEsSAOoWeSEt4qSH4pnx2B2zxWB1N\nqoGKzZtgB02z2CihOZzTKak=\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-hdtla@ta-dentakoas-73626.iam.gserviceaccount.com",
|
||||
"client_id": "110480883504754847948",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-hdtla%40ta-dentakoas-73626.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import type { NextConfig } from 'next';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
// Define the base Next.js configuration
|
||||
const baseConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.slingacademy.com',
|
||||
port: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
transpilePackages: ['geist']
|
||||
};
|
||||
|
||||
let configWithPlugins = baseConfig;
|
||||
|
||||
// Conditionally enable Sentry configuration
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DISABLED) {
|
||||
configWithPlugins = withSentryConfig(configWithPlugins, {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
// FIXME: Add your Sentry organization and project names
|
||||
org: process.env.NEXT_PUBLIC_SENTRY_ORG,
|
||||
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
reactComponentAnnotation: {
|
||||
enabled: true
|
||||
},
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: '/monitoring',
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Disable Sentry telemetry
|
||||
telemetry: false
|
||||
});
|
||||
}
|
||||
|
||||
const nextConfig = configWithPlugins;
|
||||
export default nextConfig;
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,128 @@
|
|||
{
|
||||
"name": "next-shadcn-dashboard-starter",
|
||||
"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"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||
},
|
||||
"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",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@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-query": "^5.83.0",
|
||||
"@tanstack/react-query-devtools": "^5.83.0",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"eslint": "8.48.0",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"firebase": "^12.0.0",
|
||||
"firebase-admin": "^13.4.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"kbar": "^0.1.0-beta.45",
|
||||
"lucide-react": "^0.476.0",
|
||||
"match-sorter": "^8.0.0",
|
||||
"motion": "^11.17.0",
|
||||
"next": "15.3.2",
|
||||
"next-firebase-auth-edge": "^1.9.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^3.7.15",
|
||||
"nuqs": "^2.4.3",
|
||||
"postcss": "8.4.49",
|
||||
"prisma": "^5.22.0",
|
||||
"react": "19.0.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-responsive": "^10.0.0",
|
||||
"recharts": "^2.15.1",
|
||||
"sharp": "^0.33.5",
|
||||
"sonner": "^2.0.6",
|
||||
"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.2"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
const db = globalForPrisma.prisma || new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
||||
|
||||
export default db;
|
|
@ -0,0 +1,341 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String? @unique @db.VarChar(50)
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
password String?
|
||||
phone String? @db.VarChar(13)
|
||||
address String?
|
||||
image String?
|
||||
role Role?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updateAt DateTime @updatedAt @map("updated_at")
|
||||
familyName String?
|
||||
givenName String?
|
||||
Account Account[]
|
||||
FasilitatorProfile FasilitatorProfile?
|
||||
KoasProfile KoasProfile?
|
||||
Like Like[]
|
||||
sender Notification[] @relation("Sender")
|
||||
recipient Notification[] @relation("Recipient")
|
||||
PasienProfile PasienProfile?
|
||||
Post Post[]
|
||||
Review Review[]
|
||||
sessions Session[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Account {
|
||||
type String?
|
||||
provider String
|
||||
scope String?
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
id_token String? @db.Text
|
||||
providerAccountId String @map("provider_account_id")
|
||||
refresh_token String? @db.Text
|
||||
token_type String?
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@index([userId], map: "accounts_user_id_fkey")
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
expires DateTime
|
||||
sessionToken String @map("session_token") @db.Text
|
||||
userId String? @map("user_id")
|
||||
accessToken String? @map("access_token") @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId], map: "sessions_user_id_fkey")
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model Otp {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
otp String @unique
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([email, otp])
|
||||
@@map("otps")
|
||||
}
|
||||
|
||||
model KoasProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
koasNumber String? @unique @map("koas_number")
|
||||
age String?
|
||||
gender String?
|
||||
departement String?
|
||||
university String?
|
||||
bio String?
|
||||
whatsappLink String? @map("whatsapp_link")
|
||||
status StatusKoas @default(Pending)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updateAt DateTime @updatedAt @map("updated_at")
|
||||
universityId String?
|
||||
experience Int? @default(0)
|
||||
Appointment Appointment[]
|
||||
University University? @relation(fields: [universityId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
Notification Notification[]
|
||||
Post Post[]
|
||||
Review Review[]
|
||||
|
||||
@@index([universityId], map: "koas-profile_universityId_fkey")
|
||||
@@map("koas_profile")
|
||||
}
|
||||
|
||||
model PasienProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
age String?
|
||||
gender String?
|
||||
bio String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updateAt DateTime @updatedAt @map("updated_at")
|
||||
Appointment Appointment[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("pasien_profile")
|
||||
}
|
||||
|
||||
model FasilitatorProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
university String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("fasilitator_profile")
|
||||
}
|
||||
|
||||
model University {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
alias String
|
||||
location String
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
image String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updateAt DateTime @updatedAt @map("updated_at")
|
||||
koasProfile KoasProfile[]
|
||||
|
||||
@@map("universities")
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
koasId String @map("koas_id")
|
||||
treatmentId String @map("treatment_id")
|
||||
title String
|
||||
desc String @db.VarChar(500)
|
||||
patientRequirement Json? @map("patient_requirement")
|
||||
status StatusPost @default(Pending)
|
||||
published Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updateAt DateTime @updatedAt @map("updated_at")
|
||||
requiredParticipant Int @default(0) @map("required_participant")
|
||||
images Json?
|
||||
likes Like[]
|
||||
koas KoasProfile @relation(fields: [koasId], references: [id], onDelete: Cascade)
|
||||
treatment TreatmentType @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
Review Review[]
|
||||
Schedule Schedule[]
|
||||
|
||||
@@index([userId], map: "user_id")
|
||||
@@index([koasId], map: "koas_id")
|
||||
@@index([treatmentId], map: "treatment_id")
|
||||
@@map("posts")
|
||||
}
|
||||
|
||||
model Like {
|
||||
id String @id @default(cuid())
|
||||
postId String @map("post_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
Post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([postId], map: "post_id")
|
||||
@@index([userId], map: "user_id")
|
||||
@@map("likes")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
koasId String?
|
||||
senderId String?
|
||||
status StatusNotification @default(Unread)
|
||||
title String
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String?
|
||||
koasProfile KoasProfile? @relation(fields: [koasId], references: [id], onDelete: Cascade)
|
||||
sender User? @relation("Sender", fields: [senderId], references: [id], onDelete: Cascade)
|
||||
recipient User? @relation("Recipient", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([koasId], map: "notifications_koasId_fkey")
|
||||
@@index([senderId], map: "notifications_senderId_fkey")
|
||||
@@index([userId], map: "notifications_userId_fkey")
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
model TreatmentType {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updateAt DateTime @updatedAt @map("updated_at")
|
||||
alias String
|
||||
image String?
|
||||
Post Post[]
|
||||
|
||||
@@map("treatment_types")
|
||||
}
|
||||
|
||||
model Schedule {
|
||||
id String @id @default(cuid())
|
||||
postId String @map("post_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
dateEnd DateTime @map("date_end")
|
||||
dateStart DateTime @map("date_start")
|
||||
updateAt DateTime @updatedAt @map("updated_at")
|
||||
Appointment Appointment[]
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
timeslot Timeslot[]
|
||||
|
||||
@@index([postId], map: "post_id")
|
||||
@@map("schedules")
|
||||
}
|
||||
|
||||
model Timeslot {
|
||||
id String @id @default(cuid())
|
||||
startTime String @map("start_time")
|
||||
endTime String @map("end_time")
|
||||
maxParticipants Int? @map("max_participants")
|
||||
currentParticipants Int @default(0) @map("current_participants")
|
||||
isAvailable Boolean @default(true) @map("is_available")
|
||||
scheduleId String @map("schedule_id")
|
||||
Appointment Appointment[]
|
||||
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([scheduleId, startTime, endTime], name: "unique_timeslot")
|
||||
@@map("timeslots")
|
||||
}
|
||||
|
||||
model Review {
|
||||
id String @id @default(cuid())
|
||||
postId String @map("post_id")
|
||||
pasienId String @map("user_id")
|
||||
rating Decimal @default(0.000000000000000000000000000000)
|
||||
comment String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
koasId String? @map("koas_id")
|
||||
KoasProfile KoasProfile? @relation(fields: [koasId], references: [userId])
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [pasienId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([postId], map: "post_id")
|
||||
@@index([pasienId], map: "pasien_Id")
|
||||
@@index([koasId], map: "koas_id")
|
||||
@@map("reviews")
|
||||
}
|
||||
|
||||
model Appointment {
|
||||
id String @id @default(cuid())
|
||||
pasienId String @map("pasien_id")
|
||||
koasId String @map("koas_id")
|
||||
scheduleId String @map("schedule_id")
|
||||
timeslotId String @map("timeslot_id")
|
||||
date String
|
||||
status StatusAppointment @default(Pending)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
koas KoasProfile @relation(fields: [koasId], references: [id], onDelete: Cascade)
|
||||
pasien PasienProfile @relation(fields: [pasienId], references: [id], onDelete: Cascade)
|
||||
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||
timeslot Timeslot @relation(fields: [timeslotId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([koasId], map: "appointment_koas_id_fkey")
|
||||
@@index([pasienId], map: "appointment_pasien_id_fkey")
|
||||
@@index([scheduleId], map: "appointment_schedule_id_fkey")
|
||||
@@index([timeslotId], map: "appointment_timeslot_id_fkey")
|
||||
@@map("appointments")
|
||||
}
|
||||
|
||||
model verificationrequest {
|
||||
id String @id
|
||||
token String @unique(map: "VerificationRequest_token_key")
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime
|
||||
email String
|
||||
|
||||
@@unique([email, token], map: "VerificationRequest_email_token_key")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
Admin
|
||||
Koas
|
||||
Pasien
|
||||
Fasilitator
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
enum StatusPost {
|
||||
Pending
|
||||
Open
|
||||
Closed
|
||||
|
||||
@@map("status_post")
|
||||
}
|
||||
|
||||
enum StatusKoas {
|
||||
Rejected
|
||||
Pending
|
||||
Approved
|
||||
|
||||
@@map("status_koas")
|
||||
}
|
||||
|
||||
enum StatusAppointment {
|
||||
Canceled
|
||||
Rejected
|
||||
Pending
|
||||
Confirmed
|
||||
Ongoing
|
||||
Completed
|
||||
|
||||
@@map("status_appointment")
|
||||
}
|
||||
|
||||
enum StatusNotification {
|
||||
Unread
|
||||
Read
|
||||
}
|
|
@ -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 |
|
@ -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 |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
After Width: | Height: | Size: 629 B |
|
@ -0,0 +1,11 @@
|
|||
import { Metadata } from 'next';
|
||||
import ForgotPasswordView from '@/features/auth/components/forgot-password-view';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Forgot Password',
|
||||
description: 'Reset your password by entering your email address.'
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <ForgotPasswordView />;
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import AuthLayout from '@/features/auth/components/auth-layout';
|
||||
|
||||
export default async function Layout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* <Meteors number={30} className="fixed top-0 left-0 w-full h-full" /> */}
|
||||
<AuthLayout variant='center'>{children}</AuthLayout>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import OtpVerificationView from '@/features/auth/components/otp-verification-view';
|
||||
|
||||
export default async function OtpVerification({
|
||||
params
|
||||
}: {
|
||||
params: Promise<any>;
|
||||
}) {
|
||||
await params;
|
||||
return <OtpVerificationView />;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { Metadata } from 'next';
|
||||
import PasswordResetSentView from '@/features/auth/components/password-reset-sent-view';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Password Reset Email Sent',
|
||||
description: 'Password reset email has been sent to your inbox.'
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
return <PasswordResetSentView />;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import ResetPasswordView from '@/features/auth/components/reset-password-view';
|
||||
|
||||
export default async function ResetPassword({
|
||||
params
|
||||
}: {
|
||||
params: Promise<any>;
|
||||
}) {
|
||||
await params;
|
||||
return <ResetPasswordView />;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
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() {
|
||||
return <SignInViewPage />;
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
'use server';
|
||||
|
||||
import { RoleEnum } from '@/types/models';
|
||||
import { Role } from '@prisma/client';
|
||||
import db from 'prisma/db';
|
||||
|
||||
// Helper function to check user role
|
||||
export async function checkUserRole(email: string) {
|
||||
try {
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
select: { role: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found in database');
|
||||
}
|
||||
|
||||
if (user.role !== RoleEnum.Admin) {
|
||||
throw new Error(
|
||||
'Access denied. Only administrators can access this application.'
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error('Role check error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
'use server';
|
||||
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
import { apiRequest } from '@/lib/api-utils';
|
||||
import { IAppointmentModel, StatusAppointmentEnum } from '@/types/models';
|
||||
|
||||
// Types for mutations
|
||||
export interface CreateAppointmentData {
|
||||
pasienId: string;
|
||||
koasId: string;
|
||||
scheduleId: string;
|
||||
timeslotId: string;
|
||||
date: string;
|
||||
status?: StatusAppointmentEnum;
|
||||
}
|
||||
|
||||
export interface UpdateAppointmentData {
|
||||
id: string;
|
||||
pasienId?: string;
|
||||
koasId?: string;
|
||||
scheduleId?: string;
|
||||
timeslotId?: string;
|
||||
date?: string;
|
||||
status?: StatusAppointmentEnum;
|
||||
}
|
||||
|
||||
export interface DeleteAppointmentData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Mutation functions
|
||||
export const createAppointment = async (
|
||||
data: CreateAppointmentData
|
||||
): Promise<IAppointmentModel> => {
|
||||
return apiRequest<IAppointmentModel>(ROUTES.API.APPOINTMENTS, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
status: data.status || StatusAppointmentEnum.Pending
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
export const updateAppointment = async (
|
||||
data: UpdateAppointmentData
|
||||
): Promise<IAppointmentModel> => {
|
||||
const { id, ...updateData } = data;
|
||||
|
||||
return apiRequest<IAppointmentModel>(ROUTES.API.APPOINTMENT(id), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAppointment = async (id: string): Promise<void> => {
|
||||
return apiRequest<void>(ROUTES.API.APPOINTMENT(id), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteManyAppointments = async (
|
||||
appointmentIds: string[]
|
||||
): Promise<void> => {
|
||||
return apiRequest<void>(`${ROUTES.API.APPOINTMENTS}/bulk-delete`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ids: appointmentIds })
|
||||
});
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppointmentsViewPage from '@/features/appointments/components/appointment-view-page';
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { fetchAppointments } from './queries';
|
||||
import { getQueryClient } from '@/lib/get-query-client';
|
||||
import { appointmentKeys } from '@/features/appointments/hooks/use-queries';
|
||||
|
||||
const AppointmentsPage = () => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: appointmentKeys.list({}),
|
||||
queryFn: () => fetchAppointments()
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading
|
||||
title='Appointments Management'
|
||||
description='Manage appointments'
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<AppointmentsViewPage />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppointmentsPage;
|
|
@ -0,0 +1,105 @@
|
|||
'use server';
|
||||
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
import { apiRequest, handleArrayResponse } from '@/lib/api-utils';
|
||||
import {
|
||||
IAppointmentModel,
|
||||
IScheduleModel,
|
||||
ITimeslotModel,
|
||||
StatusAppointmentEnum
|
||||
} from '@/types/models';
|
||||
|
||||
// Use existing model types
|
||||
export type Appointment = IAppointmentModel;
|
||||
export type Schedule = IScheduleModel;
|
||||
export type Timeslot = ITimeslotModel;
|
||||
|
||||
export interface AppointmentsResponse {
|
||||
appointments: Appointment[];
|
||||
totalCount?: number;
|
||||
totalPages?: number;
|
||||
currentPage?: number;
|
||||
}
|
||||
|
||||
export interface AppointmentFilters {
|
||||
pasienId?: string;
|
||||
koasId?: string;
|
||||
scheduleId?: string;
|
||||
timeslotId?: string;
|
||||
status?: StatusAppointmentEnum;
|
||||
date?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AppointmentStats {
|
||||
totalAppointments: number;
|
||||
pendingAppointments: number;
|
||||
confirmedAppointments: number;
|
||||
completedAppointments: number;
|
||||
canceledAppointments: number;
|
||||
todayAppointments: number;
|
||||
upcomingAppointments: number;
|
||||
}
|
||||
|
||||
// Fetch functions
|
||||
export const fetchAppointments = async (
|
||||
filters: AppointmentFilters = {}
|
||||
): Promise<AppointmentsResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `${ROUTES.API.APPOINTMENTS}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch appointments: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const fetchAppointmentById = async (
|
||||
id: string
|
||||
): Promise<Appointment> => {
|
||||
return apiRequest<Appointment>(`${ROUTES.API.APPOINTMENT(id)}`);
|
||||
};
|
||||
|
||||
export const fetchAppointmentStats = async (): Promise<AppointmentStats> => {
|
||||
return apiRequest<AppointmentStats>(`${ROUTES.API.APPOINTMENTS}/stats`);
|
||||
};
|
||||
|
||||
export const fetchAppointmentsByUser = async (
|
||||
userId: string
|
||||
): Promise<Appointment[]> => {
|
||||
return handleArrayResponse<Appointment>(
|
||||
`${ROUTES.API.APPOINTMENT_WITH_SPECIFIC_USER(userId)}`,
|
||||
'appointments'
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchTodayAppointments = async (): Promise<Appointment[]> => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return handleArrayResponse<Appointment>(
|
||||
`${ROUTES.API.APPOINTMENTS}?date=${today}`,
|
||||
'appointments'
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchUpcomingAppointments = async (): Promise<Appointment[]> => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return handleArrayResponse<Appointment>(
|
||||
`${ROUTES.API.APPOINTMENTS}?dateFrom=${today}&status=Confirmed`,
|
||||
'appointments'
|
||||
);
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
import KBar from '@/components/kbar';
|
||||
import AppSidebar from '@/components/layout/app-sidebar';
|
||||
import Header from '@/components/layout/header';
|
||||
import ProtectedRoute from '@/components/layout/protected-route';
|
||||
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 (
|
||||
<ProtectedRoute>
|
||||
<KBar>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
{/* page main content */}
|
||||
{children}
|
||||
{/* page main content ends */}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</KBar>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import OverviewViewPage from '@/features/overviews/overviews-page';
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { fetchStats } from './queries';
|
||||
import { getQueryClient } from '@/lib/get-query-client';
|
||||
|
||||
const Page = () => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['stats'],
|
||||
queryFn: () => fetchStats()
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-2'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading title='Welcome back✋' description='' />
|
||||
</div>
|
||||
{/* <Separator /> */}
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<OverviewViewPage />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
|
@ -0,0 +1,48 @@
|
|||
'use server';
|
||||
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
|
||||
export interface StatsResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
data: {
|
||||
users: number;
|
||||
koas: number;
|
||||
pasien: number;
|
||||
fasilitator: number;
|
||||
appointments: number;
|
||||
posts: number;
|
||||
reviews: number;
|
||||
schedules: number;
|
||||
timeslots: number;
|
||||
universities: number;
|
||||
treatmentTypes: number;
|
||||
notifications: number;
|
||||
likes: number;
|
||||
appointmentStatus: { status: string; count: number }[];
|
||||
postStatus: { status: string; count: number }[];
|
||||
koasStatus: { status: string; count: number }[];
|
||||
chartData: {
|
||||
appointmentPerDay: { date: string; count: number }[];
|
||||
postPerDay: { date: string; count: number }[];
|
||||
appointmentStatusDonut: { label: string; value: number }[];
|
||||
postStatusDonut: { label: string; value: number }[];
|
||||
userRole: { role: string; count: number }[];
|
||||
koasPerUniversity: {
|
||||
universityId: string;
|
||||
university: string;
|
||||
count: number;
|
||||
}[];
|
||||
pasienGender: { gender: string | null; count: number }[];
|
||||
koasStatus: { label: string; value: number }[];
|
||||
reviewRating: { rating: string; count: number }[];
|
||||
timeslotAvailable: { isAvailable: boolean; count: number }[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const fetchStats = async (): Promise<StatsResponse> => {
|
||||
const res = await fetch(ROUTES.API.STATS);
|
||||
if (!res.ok) throw new Error('Failed to fetch stats');
|
||||
return res.json();
|
||||
};
|
|
@ -0,0 +1,84 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
import { auth, onAuthStateChanged, signOut, User } from '@/lib/firebase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const checkUserRole = async (email: string) => {
|
||||
try {
|
||||
const response = await fetch(ROUTES.API.CHECK_ROLE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Role check error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
||||
if (currentUser && currentUser.email) {
|
||||
console.log('User is authenticated:', currentUser);
|
||||
|
||||
// Check if user has admin role
|
||||
const hasAdminRole = await checkUserRole(currentUser.email);
|
||||
|
||||
if (hasAdminRole) {
|
||||
setUser(currentUser);
|
||||
} else {
|
||||
// Sign out user if they don't have admin role
|
||||
await signOut(auth);
|
||||
toast.error(
|
||||
'Access denied. Only administrators can access this application.'
|
||||
);
|
||||
router.push(ROUTES.AUTH.SIGN_IN);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('User is not authenticated, redirecting to sign-in page');
|
||||
router.push(ROUTES.AUTH.SIGN_IN);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [router]);
|
||||
|
||||
// Redirect authenticated users to overview page
|
||||
useEffect(() => {
|
||||
if (user && !loading) {
|
||||
router.push(ROUTES.APP.DASHBOARD + '/overview');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex h-screen w-full items-center justify-center'>
|
||||
<div className='h-32 w-32 animate-spin rounded-full border-b-2 border-gray-900'></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null; // Will redirect to sign-in
|
||||
}
|
||||
|
||||
return null; // Will redirect to overview
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
'use server';
|
||||
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
import { apiRequest } from '@/lib/api-utils';
|
||||
import { IPostModel, StatusPostEnum } from '@/types/models';
|
||||
|
||||
// Types for mutations
|
||||
export interface CreatePostData {
|
||||
title: string;
|
||||
desc?: string;
|
||||
patientRequirement?: string;
|
||||
status: StatusPostEnum;
|
||||
published: boolean;
|
||||
requiredParticipant?: number;
|
||||
userId?: string;
|
||||
koasId?: string;
|
||||
treatmentId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
export interface UpdatePostData extends Partial<CreatePostData> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Mutation functions
|
||||
export const createPost = async (data: CreatePostData): Promise<IPostModel> => {
|
||||
return apiRequest<IPostModel>(ROUTES.API.POSTS, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePost = async (data: UpdatePostData): Promise<IPostModel> => {
|
||||
const { id, ...updateData } = data;
|
||||
|
||||
return apiRequest<IPostModel>(ROUTES.API.POST(id), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
};
|
||||
|
||||
export const deletePost = async (id: string): Promise<void> => {
|
||||
return apiRequest<void>(ROUTES.API.POST(id), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteManyPosts = async (ids: string[]): Promise<void> => {
|
||||
return apiRequest<void>(`${ROUTES.API.POSTS}/bulk-delete`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ids })
|
||||
});
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/get-query-client';
|
||||
import PostsViewPage from '@/features/posts/components/post-view-page';
|
||||
import { fetchPosts } from './queries';
|
||||
|
||||
const PostsPage = () => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: () => fetchPosts()
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading
|
||||
title='Posts Management'
|
||||
description='Manage dental treatment posts, patient requirements, and case studies.'
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<PostsViewPage />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostsPage;
|
|
@ -0,0 +1,120 @@
|
|||
'use server';
|
||||
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
import {
|
||||
apiRequest,
|
||||
handleArrayResponse,
|
||||
handleListResponse
|
||||
} from '@/lib/api-utils';
|
||||
import { IPostModel, StatusPostEnum } from '@/types/models';
|
||||
|
||||
export type Post = IPostModel;
|
||||
|
||||
export interface PostsResponse {
|
||||
posts: Post[];
|
||||
totalCount?: number;
|
||||
totalPages?: number;
|
||||
currentPage?: number;
|
||||
}
|
||||
|
||||
export interface PostFilters {
|
||||
userId?: string;
|
||||
koasId?: string;
|
||||
treatmentId?: string;
|
||||
status?: StatusPostEnum;
|
||||
published?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface PostStats {
|
||||
totalPosts: number;
|
||||
publishedPosts: number;
|
||||
draftPosts: number;
|
||||
pendingPosts: number;
|
||||
openPosts: number;
|
||||
closedPosts: number;
|
||||
}
|
||||
|
||||
// Fetch functions
|
||||
export const fetchPosts = async (
|
||||
filters: PostFilters = {}
|
||||
): Promise<PostsResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `${ROUTES.API.POSTS}`;
|
||||
const data = await apiRequest(url);
|
||||
|
||||
const result = handleListResponse<Post>(data, 'posts');
|
||||
|
||||
return {
|
||||
posts: result.items,
|
||||
totalCount: result.totalCount,
|
||||
totalPages: result.totalPages,
|
||||
currentPage: result.currentPage
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchPostById = async (id: string): Promise<Post> => {
|
||||
const response = await fetch(`/api/posts/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch post: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const fetchPostStats = async (): Promise<PostStats> => {
|
||||
const response = await fetch('/api/posts/stats');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch post stats: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const fetchPostsByUser = async (userId: string): Promise<Post[]> => {
|
||||
const response = await fetch(`/api/posts?userId=${userId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch posts by user: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
};
|
||||
|
||||
export const fetchPostsByKoas = async (koasId: string): Promise<Post[]> => {
|
||||
const response = await fetch(`/api/posts?koasId=${koasId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch posts by koas: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
};
|
||||
|
||||
export const fetchPublishedPosts = async (): Promise<Post[]> => {
|
||||
const response = await fetch('/api/posts?published=true');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch published posts: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || [];
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import PageContainer from '@/components/layout/page-container';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { DataTableSkeleton } from '@/components/ui/table/data-table-skeleton';
|
||||
import ProductListingPage from '@/features/products/components/product-listing';
|
||||
import { searchParamsCache, serialize } from '@/lib/searchparams';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { SearchParams } from 'nuqs/server';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard: Products'
|
||||
};
|
||||
|
||||
type pageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: pageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
// Allow nested RSCs to access the search params (in a type-safe way)
|
||||
searchParamsCache.parse(searchParams);
|
||||
|
||||
// This key is used for invoke suspense if any of the search params changed (used for filters).
|
||||
// const key = serialize({ ...searchParams });
|
||||
|
||||
return (
|
||||
<PageContainer scrollable={false}>
|
||||
<div className='flex flex-1 flex-col space-y-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading
|
||||
title='Products'
|
||||
description='Manage products (Server side table functionalities.)'
|
||||
/>
|
||||
<Link
|
||||
href='/dashboard/product/new'
|
||||
className={cn(buttonVariants(), 'text-xs md:text-sm')}
|
||||
>
|
||||
<IconPlus className='mr-2 h-4 w-4' /> Add New
|
||||
</Link>
|
||||
</div>
|
||||
<Separator />
|
||||
<Suspense
|
||||
// key={key}
|
||||
fallback={
|
||||
<DataTableSkeleton columnCount={5} rowCount={8} filterCount={2} />
|
||||
}
|
||||
>
|
||||
<ProductListingPage />
|
||||
</Suspense>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AdminViewPage from '@/features/users/admin/components/admin-view-page';
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { fetchUsersByRole } from '../queries';
|
||||
import { getQueryClient } from '@/lib/get-query-client';
|
||||
import { RoleEnum } from '@/types/models';
|
||||
|
||||
const Page = () => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['users', 'role', RoleEnum.Admin],
|
||||
queryFn: () => fetchUsersByRole(RoleEnum.Admin)
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading title='Admin Users' description='Manage admin users' />
|
||||
</div>
|
||||
<Separator />
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<AdminViewPage />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
|
@ -0,0 +1,37 @@
|
|||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import FasilitatorViewPage from '@/features/users/fasilitator/components/fasilitator-view-page';
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { fetchUsersByRole } from '../queries';
|
||||
import { RoleEnum } from '@/types/models';
|
||||
import { getQueryClient } from '@/lib/get-query-client';
|
||||
|
||||
const Page = () => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['users', 'role', RoleEnum.Fasilitator],
|
||||
queryFn: () => fetchUsersByRole(RoleEnum.Fasilitator)
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading
|
||||
title='Facilitator Users'
|
||||
description='Manage facilitator users'
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<FasilitatorViewPage />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
|
@ -0,0 +1,34 @@
|
|||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import KoasViewPage from '@/features/users/koas/components/koas-view-page';
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { fetchUsersByRole } from '../queries';
|
||||
import { RoleEnum } from '@/types/models';
|
||||
import { getQueryClient } from '@/lib/get-query-client';
|
||||
|
||||
const Page = () => {
|
||||
// Prefetching data can be added here if needed
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['users', 'role', RoleEnum.Koas],
|
||||
queryFn: () => fetchUsersByRole(RoleEnum.Koas)
|
||||
});
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading title='Koas Users' description='Manage koas users' />
|
||||
</div>
|
||||
<Separator />
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<KoasViewPage />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
|
@ -0,0 +1,94 @@
|
|||
'use server';
|
||||
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
import {
|
||||
CreateUserData,
|
||||
UpdateUserData
|
||||
} from '@/features/users/admin/hooks/use-mutations';
|
||||
import { apiRequest } from '@/lib/api-utils';
|
||||
import { initFirebase } from '@/lib/firebase';
|
||||
import { initFirebaseAdmin } from '@/lib/firebase-admin';
|
||||
import { IUserModel } from '@/types/models';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
// Mutation functions
|
||||
export const createUser = async (
|
||||
userData: CreateUserData
|
||||
): Promise<IUserModel> => {
|
||||
const { profile, ...userDataWithoutProfile } = userData;
|
||||
|
||||
initFirebaseAdmin();
|
||||
// Create user in Firebase Auth first
|
||||
const auth = getAuth();
|
||||
const firebaseUser = await auth.createUser({
|
||||
email: userData.email,
|
||||
password: userData.password,
|
||||
displayName: userData.givenName + ' ' + userData.familyName,
|
||||
phoneNumber: userData.phoneNumber
|
||||
});
|
||||
|
||||
// Optionally, you can set custom claims for role
|
||||
if (userData.role) {
|
||||
await auth.setCustomUserClaims(firebaseUser.uid, { role: userData.role });
|
||||
}
|
||||
|
||||
// Create user in database
|
||||
const newUser = await apiRequest<IUserModel>(ROUTES.API.USERS, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...userDataWithoutProfile,
|
||||
profile,
|
||||
password: firebaseUser.passwordHash, // Use Firebase's password hash
|
||||
id: firebaseUser.uid
|
||||
})
|
||||
});
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
export const updateUser = async (
|
||||
userData: UpdateUserData
|
||||
): Promise<IUserModel> => {
|
||||
const { id, profile, ...updateData } = userData;
|
||||
|
||||
// Update user basic information
|
||||
const updatedUser = await apiRequest<IUserModel>(ROUTES.API.USER_DETAIL(id), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
// Update user profile if profile data is provided
|
||||
if (profile && Object.keys(profile).length > 0) {
|
||||
await apiRequest(ROUTES.API.USER_PROFILE(id), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(profile)
|
||||
});
|
||||
}
|
||||
|
||||
return updatedUser;
|
||||
};
|
||||
|
||||
export const deleteUser = async (userId: string): Promise<void> => {
|
||||
return apiRequest<void>(ROUTES.API.USER_DETAIL(userId), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteManyUsers = async (userIds: string[]): Promise<void> => {
|
||||
return apiRequest<void>(`${ROUTES.API.USERS}/bulk-delete`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ ids: userIds })
|
||||
});
|
||||
};
|
|
@ -0,0 +1,33 @@
|
|||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import PasienViewPage from '@/features/users/pasien/components/pasien-view-page';
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { fetchUsersByRole } from '../queries';
|
||||
import { RoleEnum } from '@/types/models';
|
||||
import { getQueryClient } from '@/lib/get-query-client';
|
||||
|
||||
const Page = () => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['users', 'role', RoleEnum.Pasien],
|
||||
queryFn: () => fetchUsersByRole(RoleEnum.Pasien)
|
||||
});
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className='flex flex-1 flex-col space-y-4'>
|
||||
<div className='flex items-start justify-between'>
|
||||
<Heading title='Pasien Users' description='Manage pasien users' />
|
||||
</div>
|
||||
<Separator />
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<PasienViewPage />
|
||||
</HydrationBoundary>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
|
@ -0,0 +1,60 @@
|
|||
import { ROUTES } from '@/constants/routes';
|
||||
import {
|
||||
User,
|
||||
UserFilters,
|
||||
UsersResponse
|
||||
} from '@/features/users/admin/hooks/use-queries';
|
||||
import { IUserModel } from '@/types/models';
|
||||
import {
|
||||
apiRequest,
|
||||
handleListResponse,
|
||||
handleArrayResponse
|
||||
} from '@/lib/api-utils';
|
||||
|
||||
// Fetch functions
|
||||
export const fetchUsers = async (
|
||||
filters: UserFilters = {}
|
||||
): Promise<UsersResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const url = `${ROUTES.API.USERS}?${searchParams.toString()}`;
|
||||
const data = await apiRequest(url);
|
||||
|
||||
const result = handleListResponse<User>(data, 'users');
|
||||
|
||||
return {
|
||||
users: result.items,
|
||||
totalCount: result.totalCount,
|
||||
totalPages: result.totalPages,
|
||||
currentPage: result.currentPage
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchUserById = async (id: string): Promise<User> => {
|
||||
const data = await apiRequest<User>(ROUTES.API.USER_DETAIL(id));
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchUserProfile = async (id: string): Promise<IUserModel> => {
|
||||
const data = await apiRequest<IUserModel>(ROUTES.API.USER_PROFILE(id));
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchUsersByRole = async (role: string): Promise<User[]> => {
|
||||
const data = await apiRequest(ROUTES.API.USER_WITH_ROLE(role));
|
||||
return handleArrayResponse<User>(data, 'users');
|
||||
};
|
||||
|
||||
export const checkEmailExists = async (
|
||||
email: string
|
||||
): Promise<{ exists: boolean }> => {
|
||||
const url = `${ROUTES.API.USERS}/is-email-exist?email=${encodeURIComponent(email)}`;
|
||||
const data = await apiRequest<{ exists: boolean }>(url);
|
||||
return data;
|
||||
};
|
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,216 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@import './theme.css';
|
||||
|
||||
:root {
|
||||
--background: oklch(0.9754 0.0084 325.6414);
|
||||
--foreground: oklch(0.3257 0.1161 325.0372);
|
||||
--card: oklch(0.9754 0.0084 325.6414);
|
||||
--card-foreground: oklch(0.3257 0.1161 325.0372);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.3257 0.1161 325.0372);
|
||||
--primary: oklch(0.5316 0.1409 355.1999);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.8696 0.0675 334.8991);
|
||||
--secondary-foreground: oklch(0.4448 0.1341 324.7991);
|
||||
--muted: oklch(0.9395 0.026 331.5454);
|
||||
--muted-foreground: oklch(0.4924 0.1244 324.4523);
|
||||
--accent: oklch(0.8696 0.0675 334.8991);
|
||||
--accent-foreground: oklch(0.4448 0.1341 324.7991);
|
||||
--destructive: oklch(0.55 0.19 27); /* Lebih merah, lebih vivid */
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.8568 0.0829 328.911);
|
||||
--input: oklch(0.8517 0.0558 336.6002);
|
||||
--ring: oklch(0.5916 0.218 0.5844);
|
||||
--chart-1: oklch(0.6038 0.2363 344.4657);
|
||||
--chart-2: oklch(0.4445 0.2251 300.6246);
|
||||
--chart-3: oklch(0.379 0.0438 226.1538);
|
||||
--chart-4: oklch(0.833 0.1185 88.3461);
|
||||
--chart-5: oklch(0.7843 0.1256 58.9964);
|
||||
--sidebar: oklch(0.936 0.0288 320.5788);
|
||||
--sidebar-foreground: oklch(0.4948 0.1909 354.5435);
|
||||
--sidebar-primary: oklch(0.3963 0.0251 285.1962);
|
||||
--sidebar-primary-foreground: oklch(0.9668 0.0124 337.5228);
|
||||
--sidebar-accent: oklch(0.9789 0.0013 106.4235);
|
||||
--sidebar-accent-foreground: oklch(0.3963 0.0251 285.1962);
|
||||
--sidebar-border: oklch(0.9383 0.0026 48.7178);
|
||||
--sidebar-ring: oklch(0.5916 0.218 0.5844);
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
--radius: 0.5rem;
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 2px 4px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 4px 6px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 8px 10px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--tracking-normal: 0em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.2409 0.0201 307.5346);
|
||||
--foreground: oklch(0.8398 0.0387 309.5391);
|
||||
--card: oklch(0.2803 0.0232 307.5413);
|
||||
--card-foreground: oklch(0.8456 0.0302 341.4597);
|
||||
--popover: oklch(0.1548 0.0132 338.9015);
|
||||
--popover-foreground: oklch(0.9647 0.0091 341.8035);
|
||||
--primary: oklch(0.4607 0.1853 4.0994);
|
||||
--primary-foreground: oklch(0.856 0.0618 346.3684);
|
||||
--secondary: oklch(0.3137 0.0306 310.061);
|
||||
--secondary-foreground: oklch(0.8483 0.0382 307.9613);
|
||||
--muted: oklch(0.2634 0.0219 309.4748);
|
||||
--muted-foreground: oklch(0.794 0.0372 307.1032);
|
||||
--accent: oklch(0.3649 0.0508 308.4911);
|
||||
--accent-foreground: oklch(0.9647 0.0091 341.8035);
|
||||
--destructive: oklch(
|
||||
0.45 0.19 27
|
||||
); /* Lebih merah, lebih vivid untuk dark mode */
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(0.3286 0.0154 343.4461);
|
||||
--input: oklch(0.3387 0.0195 332.8347);
|
||||
--ring: oklch(0.5916 0.218 0.5844);
|
||||
--chart-1: oklch(0.5316 0.1409 355.1999);
|
||||
--chart-2: oklch(0.5633 0.1912 306.8561);
|
||||
--chart-3: oklch(0.7227 0.1502 60.5799);
|
||||
--chart-4: oklch(0.6193 0.2029 312.7422);
|
||||
--chart-5: oklch(0.6118 0.2093 6.1387);
|
||||
--sidebar: oklch(0.1893 0.0163 331.0475);
|
||||
--sidebar-foreground: oklch(0.8607 0.0293 343.6612);
|
||||
--sidebar-primary: oklch(0.4882 0.2172 264.3763);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.2337 0.0261 338.1961);
|
||||
--sidebar-accent-foreground: oklch(0.9674 0.0013 286.3752);
|
||||
--sidebar-border: oklch(0 0 0);
|
||||
--sidebar-ring: oklch(0.5916 0.218 0.5844);
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
--radius: 0.5rem;
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 2px 4px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 4px 6px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 8px 10px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--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-destructive-foreground: var(--destructive-foreground);
|
||||
--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);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import Providers from '@/components/layout/providers';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { fontVariables } from '@/lib/font';
|
||||
import ThemeProvider from '@/components/layout/ThemeToggle/theme-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import './globals.css';
|
||||
import './theme.css';
|
||||
import ReactQueryProvider from '@/lib/react-query-provider';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
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
|
||||
)}
|
||||
suppressHydrationWarning={true}
|
||||
>
|
||||
<NextTopLoader showSpinner={false} />
|
||||
<ReactQueryProvider>
|
||||
<ReactQueryDevtools />
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
>
|
||||
<Providers activeThemeValue={activeThemeValue as string}>
|
||||
<Toaster position='top-right' />
|
||||
{children}
|
||||
</Providers>
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</ReactQueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
import { auth, onAuthStateChanged, signOut } from '@/lib/firebase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Page() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const checkUserRole = async (email: string) => {
|
||||
try {
|
||||
const response = await fetch(ROUTES.API.CHECK_ROLE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Role check error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, async (user) => {
|
||||
if (user && user.email) {
|
||||
console.log('User is authenticated:', user);
|
||||
|
||||
// Check if user has admin role
|
||||
const hasAdminRole = await checkUserRole(user.email);
|
||||
|
||||
if (hasAdminRole) {
|
||||
router.push(ROUTES.APP.DASHBOARD);
|
||||
} else {
|
||||
// Sign out user if they don't have admin role
|
||||
await signOut(auth);
|
||||
toast.error(
|
||||
'Access denied. Only administrators can access this application.'
|
||||
);
|
||||
router.push(ROUTES.AUTH.SIGN_IN);
|
||||
}
|
||||
} else {
|
||||
console.log('User is not authenticated, redirecting to sign-in page');
|
||||
router.push(ROUTES.AUTH.SIGN_IN);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex h-screen w-full items-center justify-center'>
|
||||
<div className='h-32 w-32 animate-spin rounded-full border-b-2 border-gray-900'></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
body {
|
||||
@apply overscroll-none bg-transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: var(--font-inter);
|
||||
--header-height: calc(var(--spacing) * 12 + 1px);
|
||||
}
|
||||
|
||||
.theme-scaled {
|
||||
@media (min-width: 1024px) {
|
||||
--radius: 0.6rem;
|
||||
--text-lg: 1.05rem;
|
||||
--text-base: 0.85rem;
|
||||
--text-sm: 0.8rem;
|
||||
--spacing: 0.222222rem;
|
||||
}
|
||||
|
||||
[data-slot='card'] {
|
||||
--spacing: 0.16rem;
|
||||
}
|
||||
|
||||
[data-slot='select-trigger'],
|
||||
[data-slot='toggle-group-item'] {
|
||||
--spacing: 0.222222rem;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-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-blue,
|
||||
.theme-blue-scaled {
|
||||
--primary: var(--color-blue-600);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-blue-500);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-green,
|
||||
.theme-green-scaled {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-amber,
|
||||
.theme-amber-scaled {
|
||||
--primary: var(--color-amber-600);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-amber-500);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-mono,
|
||||
.theme-mono-scaled {
|
||||
--font-sans: var(--font-mono);
|
||||
--primary: var(--color-neutral-600);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-neutral-500);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
}
|
||||
|
||||
.rounded-xs,
|
||||
.rounded-sm,
|
||||
.rounded-md,
|
||||
.rounded-lg,
|
||||
.rounded-xl {
|
||||
@apply !rounded-none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.shadow-xs,
|
||||
.shadow-sm,
|
||||
.shadow-md,
|
||||
.shadow-lg,
|
||||
.shadow-xl {
|
||||
@apply !shadow-none;
|
||||
}
|
||||
|
||||
[data-slot='toggle-group'],
|
||||
[data-slot='toggle-group-item'] {
|
||||
@apply !rounded-none !shadow-none;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
'use client';
|
||||
|
||||
import { useQueryState } from 'nuqs';
|
||||
import * as React from 'react';
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '@/components/ui/tooltip';
|
||||
import { type FlagConfig, flagConfig } from '@/config/flag';
|
||||
|
||||
type FilterFlag = FlagConfig['featureFlags'][number]['value'];
|
||||
|
||||
interface FeatureFlagsContextValue {
|
||||
filterFlag: FilterFlag;
|
||||
enableAdvancedFilter: boolean;
|
||||
}
|
||||
|
||||
const FeatureFlagsContext =
|
||||
React.createContext<FeatureFlagsContextValue | null>(null);
|
||||
|
||||
export function useFeatureFlags() {
|
||||
const context = React.useContext(FeatureFlagsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFeatureFlags must be used within a FeatureFlagsProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface FeatureFlagsProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
|
||||
const [filterFlag, setFilterFlag] = useQueryState<FilterFlag | null>(
|
||||
'filterFlag',
|
||||
{
|
||||
parse: (value) => {
|
||||
if (!value) return null;
|
||||
const validValues = flagConfig.featureFlags.map((flag) => flag.value);
|
||||
return validValues.includes(value as FilterFlag)
|
||||
? (value as FilterFlag)
|
||||
: null;
|
||||
},
|
||||
serialize: (value) => value ?? '',
|
||||
defaultValue: null,
|
||||
clearOnDefault: true,
|
||||
shallow: false,
|
||||
eq: (a, b) => (!a && !b) || a === b
|
||||
}
|
||||
);
|
||||
|
||||
const onFilterFlagChange = React.useCallback(
|
||||
(value: FilterFlag) => {
|
||||
setFilterFlag(value);
|
||||
},
|
||||
[setFilterFlag]
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo<FeatureFlagsContextValue>(
|
||||
() => ({
|
||||
filterFlag,
|
||||
enableAdvancedFilter:
|
||||
filterFlag === 'advancedFilters' || filterFlag === 'commandFilters'
|
||||
}),
|
||||
[filterFlag]
|
||||
);
|
||||
|
||||
return (
|
||||
<FeatureFlagsContext.Provider value={contextValue}>
|
||||
<div className='w-full overflow-x-auto p-1'>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
value={filterFlag}
|
||||
onValueChange={onFilterFlagChange}
|
||||
className='w-fit gap-0'
|
||||
>
|
||||
{flagConfig.featureFlags.map((flag) => (
|
||||
<Tooltip key={flag.value} delayDuration={700}>
|
||||
<ToggleGroupItem
|
||||
value={flag.value}
|
||||
className='data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90 px-3 text-xs whitespace-nowrap'
|
||||
asChild
|
||||
>
|
||||
<TooltipTrigger>
|
||||
<flag.icon className='size-3.5 shrink-0' />
|
||||
{flag.label}
|
||||
</TooltipTrigger>
|
||||
</ToggleGroupItem>
|
||||
<TooltipContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={6}
|
||||
className='bg-background text-foreground flex flex-col gap-1.5 border py-2 font-semibold [&>span]:hidden'
|
||||
>
|
||||
<div>{flag.tooltipTitle}</div>
|
||||
<p className='text-muted-foreground text-xs text-balance'>
|
||||
{flag.tooltipDescription}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{children}
|
||||
</FeatureFlagsContext.Provider>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconCommand,
|
||||
IconCreditCard,
|
||||
IconFile,
|
||||
IconFileText,
|
||||
IconHelpCircle,
|
||||
IconPhoto,
|
||||
IconDeviceLaptop,
|
||||
IconLayoutDashboard,
|
||||
IconLoader2,
|
||||
IconLogin,
|
||||
IconProps,
|
||||
IconShoppingBag,
|
||||
IconMoon,
|
||||
IconDotsVertical,
|
||||
IconPizza,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconTrash,
|
||||
IconBrandTwitter,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUserEdit,
|
||||
IconUserX,
|
||||
IconX,
|
||||
IconLayoutKanban,
|
||||
IconBrandGithub,
|
||||
IconCalendar
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export type Icon = React.ComponentType<IconProps>;
|
||||
|
||||
export const Icons = {
|
||||
dashboard: IconLayoutDashboard,
|
||||
logo: IconCommand,
|
||||
login: IconLogin,
|
||||
close: IconX,
|
||||
product: IconShoppingBag,
|
||||
spinner: IconLoader2,
|
||||
kanban: IconLayoutKanban,
|
||||
chevronLeft: IconChevronLeft,
|
||||
chevronRight: IconChevronRight,
|
||||
trash: IconTrash,
|
||||
employee: IconUserX,
|
||||
post: IconFileText,
|
||||
page: IconFile,
|
||||
userPen: IconUserEdit,
|
||||
user2: IconUserCircle,
|
||||
media: IconPhoto,
|
||||
settings: IconSettings,
|
||||
billing: IconCreditCard,
|
||||
ellipsis: IconDotsVertical,
|
||||
add: IconPlus,
|
||||
warning: IconAlertTriangle,
|
||||
user: IconUser,
|
||||
arrowRight: IconArrowRight,
|
||||
help: IconHelpCircle,
|
||||
pizza: IconPizza,
|
||||
sun: IconSun,
|
||||
moon: IconMoon,
|
||||
laptop: IconDeviceLaptop,
|
||||
github: IconBrandGithub,
|
||||
twitter: IconBrandTwitter,
|
||||
check: IconCheck,
|
||||
appointment: IconCalendar, // gunakan IconCalendar untuk appointment
|
||||
admin: IconUserCircle,
|
||||
fasilitator: IconUserEdit, // gunakan IconUserEdit untuk fasilitator
|
||||
koas: IconUser, // gunakan IconUser untuk koas
|
||||
pasien: IconUserCircle // tetap gunakan IconUserCircle untuk pasien
|
||||
};
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 ?? ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import type { ActionId, ActionImpl } from 'kbar';
|
||||
import * as React from 'react';
|
||||
|
||||
const ResultItem = React.forwardRef(
|
||||
(
|
||||
{
|
||||
action,
|
||||
active,
|
||||
currentRootActionId
|
||||
}: {
|
||||
action: ActionImpl;
|
||||
active: boolean;
|
||||
currentRootActionId: ActionId;
|
||||
},
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
const ancestors = React.useMemo(() => {
|
||||
if (!currentRootActionId) return action.ancestors;
|
||||
const index = action.ancestors.findIndex(
|
||||
(ancestor) => ancestor.id === currentRootActionId
|
||||
);
|
||||
return action.ancestors.slice(index + 1);
|
||||
}, [action.ancestors, currentRootActionId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative z-10 flex cursor-pointer items-center justify-between px-4 py-3`}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
id='kbar-result-item'
|
||||
className='border-primary bg-accent/50 absolute inset-0 z-[-1]! border-l-4'
|
||||
></div>
|
||||
)}
|
||||
<div className='relative z-10 flex items-center gap-2'>
|
||||
{action.icon && action.icon}
|
||||
<div className='flex flex-col'>
|
||||
<div>
|
||||
{ancestors.length > 0 &&
|
||||
ancestors.map((ancestor) => (
|
||||
<React.Fragment key={ancestor.id}>
|
||||
<span className='text-muted-foreground mr-2'>
|
||||
{ancestor.name}
|
||||
</span>
|
||||
<span className='mr-2'>›</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span>{action.name}</span>
|
||||
</div>
|
||||
{action.subtitle && (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{action.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{action.shortcut?.length ? (
|
||||
<div className='relative z-10 grid grid-flow-col gap-1'>
|
||||
{action.shortcut.map((sc, i) => (
|
||||
<kbd
|
||||
key={sc + i}
|
||||
className='bg-muted flex h-5 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium'
|
||||
>
|
||||
{sc}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ResultItem.displayName = 'KBarResultItem';
|
||||
|
||||
export default ResultItem;
|
|
@ -0,0 +1,36 @@
|
|||
import { useRegisterActions } from 'kbar';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
const useThemeSwitching = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
const themeAction = [
|
||||
{
|
||||
id: 'toggleTheme',
|
||||
name: 'Toggle Theme',
|
||||
shortcut: ['t', 't'],
|
||||
section: 'Theme',
|
||||
perform: toggleTheme
|
||||
},
|
||||
{
|
||||
id: 'setLightTheme',
|
||||
name: 'Set Light Theme',
|
||||
section: 'Theme',
|
||||
perform: () => setTheme('light')
|
||||
},
|
||||
{
|
||||
id: 'setDarkTheme',
|
||||
name: 'Set Dark Theme',
|
||||
section: 'Theme',
|
||||
perform: () => setTheme('dark')
|
||||
}
|
||||
];
|
||||
|
||||
useRegisterActions(themeAction, [theme]);
|
||||
};
|
||||
|
||||
export default useThemeSwitching;
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
ThemeProviderProps
|
||||
} from 'next-themes';
|
||||
|
||||
export default function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import { IconBrightness } from '@tabler/icons-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const handleThemeToggle = React.useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
const newMode = resolvedTheme === 'dark' ? 'light' : 'dark';
|
||||
const root = document.documentElement;
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
setTheme(newMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set coordinates from the click event
|
||||
if (e) {
|
||||
root.style.setProperty('--x', `${e.clientX}px`);
|
||||
root.style.setProperty('--y', `${e.clientY}px`);
|
||||
}
|
||||
|
||||
document.startViewTransition(() => {
|
||||
setTheme(newMode);
|
||||
});
|
||||
},
|
||||
[resolvedTheme, setTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='icon'
|
||||
className='group/toggle size-8'
|
||||
onClick={handleThemeToggle}
|
||||
>
|
||||
<IconBrightness />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,239 @@
|
|||
'use client';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail
|
||||
} from '@/components/ui/sidebar';
|
||||
import { UserAvatarProfile } from '@/components/user-avatar-profile';
|
||||
import { navItems } from '@/constants/data';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
import useAuthUser from '@/features/auth/hooks/use-auth-user';
|
||||
import { useSignOutMutation } from '@/features/auth/hooks/use-mutation';
|
||||
import {
|
||||
IconBell,
|
||||
IconChevronRight,
|
||||
IconChevronsDown,
|
||||
IconCreditCard,
|
||||
IconLogout,
|
||||
IconPhotoUp,
|
||||
IconUserCircle
|
||||
} 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 { toast } from 'sonner';
|
||||
export const company = {
|
||||
name: 'Acme Inc',
|
||||
logo: IconPhotoUp,
|
||||
plan: 'Enterprise'
|
||||
};
|
||||
|
||||
const tenants = [
|
||||
{ id: '1', name: 'Acme Inc' },
|
||||
{ id: '2', name: 'Beta Corp' },
|
||||
{ id: '3', name: 'Gamma Ltd' }
|
||||
];
|
||||
|
||||
export default function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isOpen } = useMediaQuery();
|
||||
const { data: user } = useAuthUser();
|
||||
const signOutMutation = useSignOutMutation();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSwitchTenant = (_tenantId: string) => {
|
||||
// Tenant switching functionality would be implemented here
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success('Signed out successfully');
|
||||
router.push('/auth/sign-in');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Sign out failed');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const activeTenant = tenants[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
// Side effects based on sidebar state changes
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible='icon'>
|
||||
<SidebarHeader>
|
||||
<OrgSwitcher
|
||||
tenants={tenants}
|
||||
defaultTenant={activeTenant}
|
||||
onTenantSwitch={handleSwitchTenant}
|
||||
/>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className='overflow-x-hidden'>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Overview</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
||||
return item?.items && item?.items?.length > 0 ? (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
{item.icon && <Icon />}
|
||||
<span>{item.title}</span>
|
||||
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === subItem.url}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
{user && (
|
||||
<UserAvatarProfile
|
||||
className='h-8 w-8 rounded-lg'
|
||||
showInfo
|
||||
user={{
|
||||
imageUrl: user.photoURL || undefined,
|
||||
fullName: user.displayName,
|
||||
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={{
|
||||
imageUrl: user.photoURL || undefined,
|
||||
fullName: user.displayName,
|
||||
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={handleSignOut}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
<IconLogout className='mr-2 h-4 w-4' />
|
||||
{signOutMutation.isPending ? 'Signing out...' : 'Sign out'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
|
@ -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='/'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
className='dark:text-foreground'
|
||||
>
|
||||
<IconBrandGithub />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
|
@ -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 />
|
||||
<UserNav />
|
||||
<div className='hidden md:flex'>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<ModeToggle />
|
||||
{/* <ThemeSelector /> */}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export default function PageContainer({
|
||||
children,
|
||||
scrollable = true
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scrollable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{scrollable ? (
|
||||
<ScrollArea className='h-[calc(100dvh-52px)]'>
|
||||
<div className='flex flex-1 p-4 md:px-6'>{children}</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className='flex flex-1 p-4 md:px-6'>{children}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ROUTES } from '@/constants/routes';
|
||||
import { auth, onAuthStateChanged, signOut, type User } from '@/lib/firebase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const checkUserRole = async (email: string) => {
|
||||
try {
|
||||
const response = await fetch(ROUTES.API.CHECK_ROLE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Role check failed');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Role check error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
||||
if (currentUser && currentUser.email) {
|
||||
console.log('User is authenticated:', currentUser);
|
||||
|
||||
// Check if user has admin role
|
||||
const hasAdminRole = await checkUserRole(currentUser.email);
|
||||
|
||||
if (hasAdminRole) {
|
||||
setUser(currentUser);
|
||||
setIsAuthorized(true);
|
||||
} else {
|
||||
// Sign out user if they don't have admin role
|
||||
await signOut(auth);
|
||||
toast.error(
|
||||
'Access denied. Only administrators can access this application.'
|
||||
);
|
||||
router.push(ROUTES.AUTH.SIGN_IN);
|
||||
}
|
||||
} else {
|
||||
console.log('User is not authenticated, redirecting to sign-in page');
|
||||
setUser(null);
|
||||
setIsAuthorized(false);
|
||||
router.push(ROUTES.AUTH.SIGN_IN);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex h-screen w-full items-center justify-center'>
|
||||
<div className='h-32 w-32 animate-spin rounded-full border-b-2 border-gray-900 dark:border-gray-100'></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAuthorized) {
|
||||
return null; // Will redirect to sign-in
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
'use client';
|
||||
import React from 'react';
|
||||
import { ActiveThemeProvider } from '../active-theme';
|
||||
|
||||
export default function Providers({
|
||||
activeThemeValue,
|
||||
children
|
||||
}: {
|
||||
activeThemeValue: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ActiveThemeProvider initialTheme={activeThemeValue}>
|
||||
{children}
|
||||
</ActiveThemeProvider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
'use client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UserAvatarProfile } from '@/components/user-avatar-profile';
|
||||
import { useSignOutMutation } from '@/features/auth/hooks/use-mutation';
|
||||
import useAuthUser from '@/features/auth/hooks/use-auth-user';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function UserNav() {
|
||||
const { data: user } = useAuthUser();
|
||||
const signOutMutation = useSignOutMutation();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success('Signed out successfully');
|
||||
router.push('/auth/sign-in');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Sign out failed');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (user) {
|
||||
const userData = {
|
||||
imageUrl: user.photoURL || undefined,
|
||||
fullName: user.displayName,
|
||||
emailAddresses: [{ emailAddress: user.email || '' }]
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
|
||||
<UserAvatarProfile user={userData} />
|
||||
</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.displayName || 'User'}
|
||||
</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={handleSignOut}
|
||||
disabled={signOutMutation.isPending}
|
||||
>
|
||||
{signOutMutation.isPending ? 'Signing out...' : 'Sign out'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
motion,
|
||||
useTransform,
|
||||
AnimatePresence,
|
||||
useMotionValue,
|
||||
useSpring
|
||||
} from 'framer-motion';
|
||||
|
||||
export const AnimatedTooltip = ({
|
||||
items
|
||||
}: {
|
||||
items: {
|
||||
id: number;
|
||||
name: string;
|
||||
designation: string;
|
||||
image: string;
|
||||
}[];
|
||||
}) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const springConfig = { stiffness: 100, damping: 5 };
|
||||
const x = useMotionValue(0); // going to set this value on mouse move
|
||||
// rotate the tooltip
|
||||
const rotate = useSpring(
|
||||
useTransform(x, [-100, 100], [-45, 45]),
|
||||
springConfig
|
||||
);
|
||||
// translate the tooltip
|
||||
const translateX = useSpring(
|
||||
useTransform(x, [-100, 100], [-50, 50]),
|
||||
springConfig
|
||||
);
|
||||
const handleMouseMove = (event: any) => {
|
||||
const halfWidth = event.target.offsetWidth / 2;
|
||||
x.set(event.nativeEvent.offsetX - halfWidth); // set the x value, which is then used in transform and rotate
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item, idx) => (
|
||||
<div
|
||||
className='group relative -mr-4'
|
||||
key={item.name}
|
||||
onMouseEnter={() => setHoveredIndex(item.id)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
>
|
||||
<AnimatePresence mode='popLayout'>
|
||||
{hoveredIndex === item.id && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.6 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 260,
|
||||
damping: 10
|
||||
}
|
||||
}}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.6 }}
|
||||
style={{
|
||||
translateX: translateX,
|
||||
rotate: rotate,
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
className='absolute -top-16 left-1/2 z-50 flex -translate-x-1/2 flex-col items-center justify-center rounded-md bg-black px-4 py-2 text-xs shadow-xl'
|
||||
>
|
||||
<div className='absolute inset-x-10 -bottom-px z-30 h-px w-[20%] bg-linear-to-r from-transparent via-emerald-500 to-transparent' />
|
||||
<div className='absolute -bottom-px left-10 z-30 h-px w-[40%] bg-linear-to-r from-transparent via-sky-500 to-transparent' />
|
||||
<div className='relative z-30 text-base font-bold text-white'>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className='text-xs text-white'>{item.designation}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<img
|
||||
onMouseMove={handleMouseMove}
|
||||
height={100}
|
||||
width={100}
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className='relative m-0! h-14 w-14 rounded-full border-2 border-white object-cover object-top p-0! transition duration-500 group-hover:z-30 group-hover:scale-105'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 };
|
|
@ -0,0 +1,260 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
|
||||
export const BackgroundBeamsWithCollision = ({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const beams = [
|
||||
{
|
||||
initialX: 10,
|
||||
translateX: 10,
|
||||
duration: 7,
|
||||
repeatDelay: 3,
|
||||
delay: 2
|
||||
},
|
||||
{
|
||||
initialX: 600,
|
||||
translateX: 600,
|
||||
duration: 3,
|
||||
repeatDelay: 3,
|
||||
delay: 4
|
||||
},
|
||||
{
|
||||
initialX: 100,
|
||||
translateX: 100,
|
||||
duration: 7,
|
||||
repeatDelay: 7,
|
||||
className: 'h-6'
|
||||
},
|
||||
{
|
||||
initialX: 400,
|
||||
translateX: 400,
|
||||
duration: 5,
|
||||
repeatDelay: 14,
|
||||
delay: 4
|
||||
},
|
||||
{
|
||||
initialX: 800,
|
||||
translateX: 800,
|
||||
duration: 11,
|
||||
repeatDelay: 2,
|
||||
className: 'h-20'
|
||||
},
|
||||
{
|
||||
initialX: 1000,
|
||||
translateX: 1000,
|
||||
duration: 4,
|
||||
repeatDelay: 2,
|
||||
className: 'h-12'
|
||||
},
|
||||
{
|
||||
initialX: 1200,
|
||||
translateX: 1200,
|
||||
duration: 6,
|
||||
repeatDelay: 4,
|
||||
delay: 2,
|
||||
className: 'h-6'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={cn(
|
||||
'relative flex h-96 w-full items-center justify-center overflow-hidden bg-linear-to-b from-white to-neutral-100 md:h-160 dark:from-neutral-950 dark:to-neutral-800',
|
||||
// h-screen if you want bigger
|
||||
className
|
||||
)}
|
||||
>
|
||||
{beams.map((beam) => (
|
||||
<CollisionMechanism
|
||||
key={beam.initialX + 'beam-idx'}
|
||||
beamOptions={beam}
|
||||
containerRef={containerRef as React.RefObject<HTMLDivElement>}
|
||||
parentRef={parentRef as React.RefObject<HTMLDivElement>}
|
||||
/>
|
||||
))}
|
||||
|
||||
{children}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='pointer-events-none absolute inset-x-0 bottom-0 w-full bg-neutral-100'
|
||||
style={{
|
||||
boxShadow:
|
||||
'0 0 24px rgba(34, 42, 53, 0.06), 0 1px 1px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(34, 42, 53, 0.04), 0 0 4px rgba(34, 42, 53, 0.08), 0 16px 68px rgba(47, 48, 55, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1) inset'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CollisionMechanism = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
beamOptions?: {
|
||||
initialX?: number;
|
||||
translateX?: number;
|
||||
initialY?: number;
|
||||
translateY?: number;
|
||||
rotate?: number;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
repeatDelay?: number;
|
||||
};
|
||||
}
|
||||
>(({ parentRef, containerRef, beamOptions = {} }, ref) => {
|
||||
const beamRef = useRef<HTMLDivElement>(null);
|
||||
const [collision, setCollision] = useState<{
|
||||
detected: boolean;
|
||||
coordinates: { x: number; y: number } | null;
|
||||
}>({
|
||||
detected: false,
|
||||
coordinates: null
|
||||
});
|
||||
const [beamKey, setBeamKey] = useState(0);
|
||||
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCollision = () => {
|
||||
if (
|
||||
beamRef.current &&
|
||||
containerRef.current &&
|
||||
parentRef.current &&
|
||||
!cycleCollisionDetected
|
||||
) {
|
||||
const beamRect = beamRef.current.getBoundingClientRect();
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const parentRect = parentRef.current.getBoundingClientRect();
|
||||
|
||||
if (beamRect.bottom >= containerRect.top) {
|
||||
const relativeX =
|
||||
beamRect.left - parentRect.left + beamRect.width / 2;
|
||||
const relativeY = beamRect.bottom - parentRect.top;
|
||||
|
||||
setCollision({
|
||||
detected: true,
|
||||
coordinates: {
|
||||
x: relativeX,
|
||||
y: relativeY
|
||||
}
|
||||
});
|
||||
setCycleCollisionDetected(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const animationInterval = setInterval(checkCollision, 50);
|
||||
|
||||
return () => clearInterval(animationInterval);
|
||||
}, [cycleCollisionDetected, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collision.detected && collision.coordinates) {
|
||||
setTimeout(() => {
|
||||
setCollision({ detected: false, coordinates: null });
|
||||
setCycleCollisionDetected(false);
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
setBeamKey((prevKey) => prevKey + 1);
|
||||
}, 2000);
|
||||
}
|
||||
}, [collision]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
key={beamKey}
|
||||
ref={beamRef}
|
||||
animate='animate'
|
||||
initial={{
|
||||
y: beamOptions.initialY || '-200px',
|
||||
x: beamOptions.initialX || '0px',
|
||||
rotate: beamOptions.rotate || 0
|
||||
}}
|
||||
variants={{
|
||||
animate: {
|
||||
y: beamOptions.translateY || '1800px',
|
||||
x: beamOptions.translateX || '0px',
|
||||
rotate: beamOptions.rotate || 0
|
||||
}
|
||||
}}
|
||||
transition={{
|
||||
duration: beamOptions.duration || 8,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
ease: 'linear',
|
||||
delay: beamOptions.delay || 0,
|
||||
repeatDelay: beamOptions.repeatDelay || 0
|
||||
}}
|
||||
className={cn(
|
||||
'absolute top-20 left-0 m-auto h-14 w-px rounded-full bg-linear-to-t from-indigo-500 via-purple-500 to-transparent',
|
||||
beamOptions.className
|
||||
)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{collision.detected && collision.coordinates && (
|
||||
<Explosion
|
||||
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
|
||||
className=''
|
||||
style={{
|
||||
left: `${collision.coordinates.x}px`,
|
||||
top: `${collision.coordinates.y}px`,
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
CollisionMechanism.displayName = 'CollisionMechanism';
|
||||
|
||||
const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
|
||||
const spans = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: index,
|
||||
initialX: 0,
|
||||
initialY: 0,
|
||||
directionX: Math.floor(Math.random() * 80 - 40),
|
||||
directionY: Math.floor(Math.random() * -50 - 10)
|
||||
}));
|
||||
|
||||
return (
|
||||
<div {...props} className={cn('absolute z-50 h-2 w-2', props.className)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1.5, ease: 'easeOut' }}
|
||||
className='absolute -inset-x-10 top-0 m-auto h-2 w-10 rounded-full bg-linear-to-r from-transparent via-indigo-500 to-transparent blur-sm'
|
||||
></motion.div>
|
||||
{spans.map((span) => (
|
||||
<motion.span
|
||||
key={span.id}
|
||||
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
|
||||
animate={{
|
||||
x: span.directionX,
|
||||
y: span.directionY,
|
||||
opacity: 0
|
||||
}}
|
||||
transition={{ duration: Math.random() * 1.5 + 0.5, ease: 'easeOut' }}
|
||||
className='absolute h-1 w-1 rounded-full bg-linear-to-b from-indigo-500 to-purple-500'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,54 @@
|
|||
'use client';
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function ColourfulText({ text }: { text: string }) {
|
||||
const colors = [
|
||||
'rgb(131, 179, 32)',
|
||||
'rgb(47, 195, 106)',
|
||||
'rgb(42, 169, 210)',
|
||||
'rgb(4, 112, 202)',
|
||||
'rgb(107, 10, 255)',
|
||||
'rgb(183, 0, 218)',
|
||||
'rgb(218, 0, 171)',
|
||||
'rgb(230, 64, 92)',
|
||||
'rgb(232, 98, 63)',
|
||||
'rgb(249, 129, 47)'
|
||||
];
|
||||
|
||||
const [currentColors, setCurrentColors] = React.useState(colors);
|
||||
const [count, setCount] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const shuffled = [...colors].sort(() => Math.random() - 0.5);
|
||||
setCurrentColors(shuffled);
|
||||
setCount((prev) => prev + 1);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return text.split('').map((char, index) => (
|
||||
<motion.span
|
||||
key={`${char}-${count}-${index}`}
|
||||
initial={{
|
||||
y: 0
|
||||
}}
|
||||
animate={{
|
||||
color: currentColors[index % currentColors.length],
|
||||
y: [0, -3, 0],
|
||||
scale: [1, 1.01, 1],
|
||||
filter: ['blur(0px)', `blur(5px)`, 'blur(0px)'],
|
||||
opacity: [1, 0.8, 1]
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: index * 0.05
|
||||
}}
|
||||
className='inline-block font-sans tracking-tight whitespace-pre'
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
));
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
'use client';
|
||||
import React, { JSX, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Card = {
|
||||
id: number;
|
||||
content: JSX.Element | React.ReactNode | string;
|
||||
className: string;
|
||||
thumbnail: string;
|
||||
};
|
||||
|
||||
export const LayoutGrid = ({ cards }: { cards: Card[] }) => {
|
||||
const [selected, setSelected] = useState<Card | null>(null);
|
||||
const [lastSelected, setLastSelected] = useState<Card | null>(null);
|
||||
|
||||
const handleClick = (card: Card) => {
|
||||
setLastSelected(selected);
|
||||
setSelected(card);
|
||||
};
|
||||
|
||||
const handleOutsideClick = () => {
|
||||
setLastSelected(selected);
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative mx-auto grid h-full w-full max-w-7xl grid-cols-1 gap-4 p-10 md:grid-cols-3'>
|
||||
{cards.map((card, i) => (
|
||||
<div key={i} className={cn(card.className, '')}>
|
||||
<motion.div
|
||||
onClick={() => handleClick(card)}
|
||||
className={cn(
|
||||
card.className,
|
||||
'relative overflow-hidden',
|
||||
selected?.id === card.id
|
||||
? 'absolute inset-0 z-50 m-auto flex h-1/2 w-full cursor-pointer flex-col flex-wrap items-center justify-center rounded-lg md:w-1/2'
|
||||
: lastSelected?.id === card.id
|
||||
? 'z-40 h-full w-full rounded-xl bg-white'
|
||||
: 'h-full w-full rounded-xl bg-white'
|
||||
)}
|
||||
layoutId={`card-${card.id}`}
|
||||
>
|
||||
{selected?.id === card.id && <SelectedCard selected={selected} />}
|
||||
<ImageComponent card={card} />
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
<motion.div
|
||||
onClick={handleOutsideClick}
|
||||
className={cn(
|
||||
'absolute top-0 left-0 z-10 h-full w-full bg-black opacity-0',
|
||||
selected?.id ? 'pointer-events-auto' : 'pointer-events-none'
|
||||
)}
|
||||
animate={{ opacity: selected?.id ? 0.3 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageComponent = ({ card }: { card: Card }) => {
|
||||
return (
|
||||
<motion.img
|
||||
layoutId={`image-${card.id}-image`}
|
||||
src={card.thumbnail}
|
||||
height='500'
|
||||
width='500'
|
||||
className={cn(
|
||||
'absolute inset-0 h-full w-full object-cover object-top transition duration-200'
|
||||
)}
|
||||
alt='thumbnail'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectedCard = ({ selected }: { selected: Card | null }) => {
|
||||
return (
|
||||
<div className='relative z-60 flex h-full w-full flex-col justify-end rounded-lg bg-transparent shadow-2xl'>
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0
|
||||
}}
|
||||
animate={{
|
||||
opacity: 0.6
|
||||
}}
|
||||
className='absolute inset-0 z-10 h-full w-full bg-black opacity-60'
|
||||
/>
|
||||
<motion.div
|
||||
layoutId={`content-${selected?.id}`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: 100
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: 'easeInOut'
|
||||
}}
|
||||
className='relative z-70 px-8 pb-4'
|
||||
>
|
||||
{selected?.content}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,73 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
interface MarqueeProps extends ComponentPropsWithoutRef<'div'> {
|
||||
/**
|
||||
* Optional CSS class name to apply custom styles
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Whether to reverse the animation direction
|
||||
* @default false
|
||||
*/
|
||||
reverse?: boolean;
|
||||
/**
|
||||
* Whether to pause the animation on hover
|
||||
* @default false
|
||||
*/
|
||||
pauseOnHover?: boolean;
|
||||
/**
|
||||
* Content to be displayed in the marquee
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Whether to animate vertically instead of horizontally
|
||||
* @default false
|
||||
*/
|
||||
vertical?: boolean;
|
||||
/**
|
||||
* Number of times to repeat the content
|
||||
* @default 4
|
||||
*/
|
||||
repeat?: number;
|
||||
}
|
||||
|
||||
export function Marquee({
|
||||
className,
|
||||
reverse = false,
|
||||
pauseOnHover = false,
|
||||
children,
|
||||
vertical = false,
|
||||
repeat = 4,
|
||||
...props
|
||||
}: MarqueeProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
'group flex gap-(--gap) overflow-hidden p-2 [--duration:40s] [--gap:1rem]',
|
||||
{
|
||||
'flex-row': !vertical,
|
||||
'flex-col': vertical
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Array(repeat)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('flex shrink-0 justify-around gap-(--gap)', {
|
||||
'animate-marquee flex-row': !vertical,
|
||||
'animate-marquee-vertical flex-col': vertical,
|
||||
'group-hover:paused': pauseOnHover,
|
||||
'[animation-direction:reverse]': reverse
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
'use client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
export const Meteors = ({
|
||||
number,
|
||||
className
|
||||
}: {
|
||||
number?: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
const meteors = new Array(number || 20).fill(true);
|
||||
|
||||
// Generate deterministic values based on index to avoid hydration mismatch
|
||||
const meteorData = useMemo(() => {
|
||||
return meteors.map((_, idx) => {
|
||||
const meteorCount = number || 20;
|
||||
// Calculate position to evenly distribute meteors across container width
|
||||
const position = idx * (800 / meteorCount) - 400; // Spread across 800px range, centered
|
||||
|
||||
// Use deterministic values based on index instead of Math.random()
|
||||
const delayMultiplier = (idx * 0.7) % 5; // Deterministic delay between 0-5s
|
||||
const durationBase = ((idx * 1.3) % 5) + 5; // Deterministic duration between 5-10s
|
||||
|
||||
return {
|
||||
position,
|
||||
delay: delayMultiplier,
|
||||
duration: Math.floor(durationBase)
|
||||
};
|
||||
});
|
||||
}, [number, meteors.length]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{meteors.map((el, idx) => {
|
||||
const meteorInfo = meteorData[idx];
|
||||
|
||||
return (
|
||||
<span
|
||||
key={'meteor' + idx}
|
||||
className={cn(
|
||||
'animate-meteor-effect absolute h-0.5 w-0.5 rotate-45 rounded-[9999px]',
|
||||
'bg-cyan-300/80 dark:bg-cyan-400/90',
|
||||
'shadow-[0_0_8px_2px_#67e8f9,0_0_0_1px_#ffffff20] dark:shadow-[0_0_8px_2px_#22d3ee,0_0_0_1px_#ffffff30]',
|
||||
'before:absolute before:top-1/2 before:h-px before:w-[50px] before:-translate-y-[50%] before:transform',
|
||||
'before:bg-gradient-to-r before:from-cyan-400 before:to-transparent',
|
||||
'dark:before:from-cyan-300 dark:before:to-transparent',
|
||||
"before:content-['']",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
top: '-40px', // Start above the container
|
||||
left: meteorInfo.position + 'px',
|
||||
animationDelay: meteorInfo.delay + 's',
|
||||
animationDuration: meteorInfo.duration + 's'
|
||||
}}
|
||||
></span>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,184 @@
|
|||
import { SVGProps } from 'react';
|
||||
|
||||
type SafariMode = 'default' | 'simple';
|
||||
|
||||
export interface SafariProps extends SVGProps<SVGSVGElement> {
|
||||
url?: string;
|
||||
imageSrc?: string;
|
||||
videoSrc?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
mode?: SafariMode;
|
||||
}
|
||||
|
||||
export function Safari({
|
||||
imageSrc,
|
||||
videoSrc,
|
||||
url,
|
||||
width = 1203,
|
||||
height = 753,
|
||||
mode = 'default',
|
||||
...props
|
||||
}: SafariProps) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<g clipPath='url(#path0)'>
|
||||
<path
|
||||
d='M0 52H1202V741C1202 747.627 1196.63 753 1190 753H12C5.37258 753 0 747.627 0 741V52Z'
|
||||
className='fill-[#E5E5E5] dark:fill-[#404040]'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M0 12C0 5.37258 5.37258 0 12 0H1190C1196.63 0 1202 5.37258 1202 12V52H0L0 12Z'
|
||||
className='fill-[#E5E5E5] dark:fill-[#404040]'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M1.06738 12C1.06738 5.92487 5.99225 1 12.0674 1H1189.93C1196.01 1 1200.93 5.92487 1200.93 12V51H1.06738V12Z'
|
||||
className='fill-white dark:fill-[#262626]'
|
||||
/>
|
||||
<circle
|
||||
cx='27'
|
||||
cy='25'
|
||||
r='6'
|
||||
className='fill-[#E5E5E5] dark:fill-[#404040]'
|
||||
/>
|
||||
<circle
|
||||
cx='47'
|
||||
cy='25'
|
||||
r='6'
|
||||
className='fill-[#E5E5E5] dark:fill-[#404040]'
|
||||
/>
|
||||
<circle
|
||||
cx='67'
|
||||
cy='25'
|
||||
r='6'
|
||||
className='fill-[#E5E5E5] dark:fill-[#404040]'
|
||||
/>
|
||||
<path
|
||||
d='M286 17C286 13.6863 288.686 11 292 11H946C949.314 11 952 13.6863 952 17V35C952 38.3137 949.314 41 946 41H292C288.686 41 286 38.3137 286 35V17Z'
|
||||
className='fill-[#E5E5E5] dark:fill-[#404040]'
|
||||
/>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M566.269 32.0852H572.426C573.277 32.0852 573.696 31.6663 573.696 30.7395V25.9851C573.696 25.1472 573.353 24.7219 572.642 24.6521V23.0842C572.642 20.6721 571.036 19.5105 569.348 19.5105C567.659 19.5105 566.053 20.6721 566.053 23.0842V24.6711C565.393 24.7727 565 25.1917 565 25.9851V30.7395C565 31.6663 565.418 32.0852 566.269 32.0852ZM567.272 22.97C567.272 21.491 568.211 20.6785 569.348 20.6785C570.478 20.6785 571.423 21.491 571.423 22.97V24.6394L567.272 24.6458V22.97Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<text
|
||||
x='580'
|
||||
y='30'
|
||||
fill='#A3A3A3'
|
||||
fontSize='12'
|
||||
fontFamily='Arial, sans-serif'
|
||||
>
|
||||
{url}
|
||||
</text>
|
||||
</g>
|
||||
{mode === 'default' ? (
|
||||
<>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M265.5 33.8984C265.641 33.8984 265.852 33.8516 266.047 33.7422C270.547 31.2969 272.109 30.1641 272.109 27.3203V21.4219C272.109 20.4844 271.742 20.1484 270.961 19.8125C270.094 19.4453 267.18 18.4297 266.328 18.1406C266.07 18.0547 265.766 18 265.5 18C265.234 18 264.93 18.0703 264.672 18.1406C263.82 18.3828 260.906 19.4531 260.039 19.8125C259.258 20.1406 258.891 20.4844 258.891 21.4219V27.3203C258.891 30.1641 260.461 31.2812 264.945 33.7422C265.148 33.8516 265.359 33.8984 265.5 33.8984ZM265.922 19.5781C266.945 19.9766 269.172 20.7656 270.344 21.1875C270.562 21.2656 270.617 21.3828 270.617 21.6641V27.0234C270.617 29.3125 269.469 29.9375 265.945 32.0625C265.727 32.1875 265.617 32.2344 265.508 32.2344V19.4844C265.617 19.4844 265.734 19.5156 265.922 19.5781Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M936.273 24.9766C936.5 24.9766 936.68 24.9062 936.82 24.7578L940.023 21.5312C940.195 21.3594 940.273 21.1719 940.273 20.9531C940.273 20.7422 940.188 20.5391 940.023 20.3828L936.82 17.125C936.68 16.9688 936.5 16.8906 936.273 16.8906C935.852 16.8906 935.516 17.2422 935.516 17.6719C935.516 17.8828 935.594 18.0547 935.727 18.2031L937.594 20.0312C937.227 19.9766 936.852 19.9453 936.477 19.9453C932.609 19.9453 929.516 23.0391 929.516 26.9141C929.516 30.7891 932.633 33.9062 936.5 33.9062C940.375 33.9062 943.484 30.7891 943.484 26.9141C943.484 26.4453 943.156 26.1094 942.688 26.1094C942.234 26.1094 941.93 26.4453 941.93 26.9141C941.93 29.9297 939.516 32.3516 936.5 32.3516C933.492 32.3516 931.07 29.9297 931.07 26.9141C931.07 23.875 933.469 21.4688 936.477 21.4688C936.984 21.4688 937.453 21.5078 937.867 21.5781L935.734 23.6875C935.594 23.8281 935.516 24 935.516 24.2109C935.516 24.6406 935.852 24.9766 936.273 24.9766Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M1134 33.0156C1134.49 33.0156 1134.89 32.6094 1134.89 32.1484V27.2578H1139.66C1140.13 27.2578 1140.54 26.8594 1140.54 26.3672C1140.54 25.8828 1140.13 25.4766 1139.66 25.4766H1134.89V20.5859C1134.89 20.1172 1134.49 19.7188 1134 19.7188C1133.52 19.7188 1133.11 20.1172 1133.11 20.5859V25.4766H1128.34C1127.88 25.4766 1127.46 25.8828 1127.46 26.3672C1127.46 26.8594 1127.88 27.2578 1128.34 27.2578H1133.11V32.1484C1133.11 32.6094 1133.52 33.0156 1134 33.0156Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M1161.8 31.0703H1163.23V32.375C1163.23 34.0547 1164.12 34.9219 1165.81 34.9219H1174.2C1175.89 34.9219 1176.77 34.0547 1176.77 32.3828V24.0469C1176.77 22.375 1175.89 21.5 1174.2 21.5H1172.77V20.2578C1172.77 18.5859 1171.88 17.7109 1170.19 17.7109H1161.8C1160.1 17.7109 1159.23 18.5781 1159.23 20.2578V28.5234C1159.23 30.1953 1160.1 31.0703 1161.8 31.0703ZM1161.9 29.5078C1161.18 29.5078 1160.78 29.1328 1160.78 28.3828V20.3984C1160.78 19.6406 1161.18 19.2656 1161.9 19.2656H1170.09C1170.8 19.2656 1171.2 19.6406 1171.2 20.3984V21.5H1165.81C1164.12 21.5 1163.23 22.375 1163.23 24.0469V29.5078H1161.9ZM1165.91 33.3672C1165.19 33.3672 1164.8 32.9922 1164.8 32.2422V24.1875C1164.8 23.4297 1165.19 23.0625 1165.91 23.0625H1174.1C1174.81 23.0625 1175.21 23.4297 1175.21 24.1875V32.2422C1175.21 32.9922 1174.81 33.3672 1174.1 33.3672H1165.91Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M1099.51 28.4141C1099.91 28.4141 1100.24 28.0859 1100.24 27.6953V19.8359L1100.18 18.6797L1100.66 19.25L1101.75 20.4141C1101.88 20.5547 1102.06 20.625 1102.24 20.625C1102.6 20.625 1102.9 20.3672 1102.9 20C1102.9 19.8047 1102.82 19.6641 1102.69 19.5312L1100.06 17.0078C1099.88 16.8203 1099.7 16.7578 1099.51 16.7578C1099.32 16.7578 1099.14 16.8203 1098.95 17.0078L1096.33 19.5312C1096.2 19.6641 1096.12 19.8047 1096.12 20C1096.12 20.3672 1096.41 20.625 1096.77 20.625C1096.95 20.625 1097.14 20.5547 1097.27 20.4141L1098.35 19.25L1098.84 18.6719L1098.78 19.8359V27.6953C1098.78 28.0859 1099.11 28.4141 1099.51 28.4141ZM1095 34.6562H1104C1105.7 34.6562 1106.57 33.7812 1106.57 32.1094V24.4297C1106.57 22.7578 1105.7 21.8828 1104 21.8828H1101.89V23.4375H1103.9C1104.61 23.4375 1105.02 23.8125 1105.02 24.5625V31.9688C1105.02 32.7188 1104.61 33.0938 1103.9 33.0938H1095.1C1094.38 33.0938 1093.98 32.7188 1093.98 31.9688V24.5625C1093.98 23.8125 1094.38 23.4375 1095.1 23.4375H1097.13V21.8828H1095C1093.31 21.8828 1092.43 22.75 1092.43 24.4297V32.1094C1092.43 33.7812 1093.31 34.6562 1095 34.6562Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M99.5703 33.6016H112.938C114.633 33.6016 115.516 32.7266 115.516 31.0547V21.5469C115.516 19.875 114.633 19 112.938 19H99.5703C97.8828 19 97 19.8672 97 21.5469V31.0547C97 32.7266 97.8828 33.6016 99.5703 33.6016ZM99.6719 32.0469C98.9531 32.0469 98.5547 31.6719 98.5547 30.9141V21.6875C98.5547 20.9297 98.9531 20.5547 99.6719 20.5547H103.234V32.0469H99.6719ZM112.836 20.5547C113.555 20.5547 113.953 20.9297 113.953 21.6875V30.9141C113.953 31.6719 113.555 32.0469 112.836 32.0469H104.711V20.5547H112.836ZM101.703 23.4141C101.984 23.4141 102.219 23.1719 102.219 22.9062C102.219 22.6406 101.984 22.4062 101.703 22.4062H100.102C99.8203 22.4062 99.5859 22.6406 99.5859 22.9062C99.5859 23.1719 99.8203 23.4141 100.102 23.4141H101.703ZM101.703 25.5156C101.984 25.5156 102.219 25.2812 102.219 25.0078C102.219 24.7422 101.984 24.5078 101.703 24.5078H100.102C99.8203 24.5078 99.5859 24.7422 99.5859 25.0078C99.5859 25.2812 99.8203 25.5156 100.102 25.5156H101.703ZM101.703 27.6094C101.984 27.6094 102.219 27.3828 102.219 27.1094C102.219 26.8438 101.984 26.6172 101.703 26.6172H100.102C99.8203 26.6172 99.5859 26.8438 99.5859 27.1094C99.5859 27.3828 99.8203 27.6094 100.102 27.6094H101.703Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M143.914 32.5938C144.094 32.7656 144.312 32.8594 144.562 32.8594C145.086 32.8594 145.492 32.4531 145.492 31.9375C145.492 31.6797 145.391 31.4453 145.211 31.2656L139.742 25.9219L145.211 20.5938C145.391 20.4141 145.492 20.1719 145.492 19.9219C145.492 19.4062 145.086 19 144.562 19C144.312 19 144.094 19.0938 143.922 19.2656L137.844 25.2031C137.625 25.4062 137.516 25.6562 137.516 25.9297C137.516 26.2031 137.625 26.4375 137.836 26.6484L143.914 32.5938Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
<g className='mix-blend-luminosity'>
|
||||
<path
|
||||
d='M168.422 32.8594C168.68 32.8594 168.891 32.7656 169.07 32.5938L175.148 26.6562C175.359 26.4375 175.469 26.2109 175.469 25.9297C175.469 25.6562 175.367 25.4141 175.148 25.2109L169.07 19.2656C168.891 19.0938 168.68 19 168.422 19C167.898 19 167.492 19.4062 167.492 19.9219C167.492 20.1719 167.602 20.4141 167.773 20.5938L173.25 25.9375L167.773 31.2656C167.594 31.4531 167.492 31.6797 167.492 31.9375C167.492 32.4531 167.898 32.8594 168.422 32.8594Z'
|
||||
fill='#A3A3A3'
|
||||
/>
|
||||
</g>
|
||||
</>
|
||||
) : null}
|
||||
{imageSrc && (
|
||||
<image
|
||||
href={imageSrc}
|
||||
width='1200'
|
||||
height='700'
|
||||
x='1'
|
||||
y='52'
|
||||
preserveAspectRatio='xMidYMid slice'
|
||||
clipPath='url(#roundedBottom)'
|
||||
/>
|
||||
)}
|
||||
{videoSrc && (
|
||||
<foreignObject
|
||||
x='1'
|
||||
y='52'
|
||||
width='1200'
|
||||
height='700'
|
||||
preserveAspectRatio='xMidYMid slice'
|
||||
clipPath='url(#roundedBottom)'
|
||||
>
|
||||
<video
|
||||
className='size-full overflow-hidden object-cover'
|
||||
src={videoSrc}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id='path0'>
|
||||
<rect width={width} height={height} fill='white' />
|
||||
</clipPath>
|
||||
<clipPath id='roundedBottom'>
|
||||
<path
|
||||
d='M1 52H1201V741C1201 747.075 1196.08 752 1190 752H12C5.92486 752 1 747.075 1 741V52Z'
|
||||
fill='white'
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,208 @@
|
|||
'use client';
|
||||
|
||||
import { motion, useSpring } from 'framer-motion';
|
||||
import { FC, JSX, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface SmoothCursorProps {
|
||||
cursor?: JSX.Element;
|
||||
springConfig?: {
|
||||
damping: number;
|
||||
stiffness: number;
|
||||
mass: number;
|
||||
restDelta: number;
|
||||
};
|
||||
}
|
||||
|
||||
const DefaultCursorSVG: FC = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={50}
|
||||
height={54}
|
||||
viewBox='0 0 50 54'
|
||||
fill='none'
|
||||
style={{ scale: 0.5 }}
|
||||
>
|
||||
<g filter='url(#filter0_d_91_7928)'>
|
||||
<path
|
||||
d='M42.6817 41.1495L27.5103 6.79925C26.7269 5.02557 24.2082 5.02558 23.3927 6.79925L7.59814 41.1495C6.75833 42.9759 8.52712 44.8902 10.4125 44.1954L24.3757 39.0496C24.8829 38.8627 25.4385 38.8627 25.9422 39.0496L39.8121 44.1954C41.6849 44.8902 43.4884 42.9759 42.6817 41.1495Z'
|
||||
fill='black'
|
||||
/>
|
||||
<path
|
||||
d='M43.7146 40.6933L28.5431 6.34306C27.3556 3.65428 23.5772 3.69516 22.3668 6.32755L6.57226 40.6778C5.3134 43.4156 7.97238 46.298 10.803 45.2549L24.7662 40.109C25.0221 40.0147 25.2999 40.0156 25.5494 40.1082L39.4193 45.254C42.2261 46.2953 44.9254 43.4347 43.7146 40.6933Z'
|
||||
stroke='white'
|
||||
strokeWidth={2.25825}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id='filter0_d_91_7928'
|
||||
x={0.602397}
|
||||
y={0.952444}
|
||||
width={49.0584}
|
||||
height={52.428}
|
||||
filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity={0} result='BackgroundImageFix' />
|
||||
<feColorMatrix
|
||||
in='SourceAlpha'
|
||||
type='matrix'
|
||||
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
|
||||
result='hardAlpha'
|
||||
/>
|
||||
<feOffset dy={2.25825} />
|
||||
<feGaussianBlur stdDeviation={2.25825} />
|
||||
<feComposite in2='hardAlpha' operator='out' />
|
||||
<feColorMatrix
|
||||
type='matrix'
|
||||
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0'
|
||||
/>
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in2='BackgroundImageFix'
|
||||
result='effect1_dropShadow_91_7928'
|
||||
/>
|
||||
<feBlend
|
||||
mode='normal'
|
||||
in='SourceGraphic'
|
||||
in2='effect1_dropShadow_91_7928'
|
||||
result='shape'
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export function SmoothCursor({
|
||||
cursor = <DefaultCursorSVG />,
|
||||
springConfig = {
|
||||
damping: 45,
|
||||
stiffness: 400,
|
||||
mass: 1,
|
||||
restDelta: 0.001
|
||||
}
|
||||
}: SmoothCursorProps) {
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
const lastMousePos = useRef<Position>({ x: 0, y: 0 });
|
||||
const velocity = useRef<Position>({ x: 0, y: 0 });
|
||||
const lastUpdateTime = useRef(Date.now());
|
||||
const previousAngle = useRef(0);
|
||||
const accumulatedRotation = useRef(0);
|
||||
|
||||
const cursorX = useSpring(0, springConfig);
|
||||
const cursorY = useSpring(0, springConfig);
|
||||
const rotation = useSpring(0, {
|
||||
...springConfig,
|
||||
damping: 60,
|
||||
stiffness: 300
|
||||
});
|
||||
const scale = useSpring(1, {
|
||||
...springConfig,
|
||||
stiffness: 500,
|
||||
damping: 35
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateVelocity = (currentPos: Position) => {
|
||||
const currentTime = Date.now();
|
||||
const deltaTime = currentTime - lastUpdateTime.current;
|
||||
|
||||
if (deltaTime > 0) {
|
||||
velocity.current = {
|
||||
x: (currentPos.x - lastMousePos.current.x) / deltaTime,
|
||||
y: (currentPos.y - lastMousePos.current.y) / deltaTime
|
||||
};
|
||||
}
|
||||
|
||||
lastUpdateTime.current = currentTime;
|
||||
lastMousePos.current = currentPos;
|
||||
};
|
||||
|
||||
const smoothMouseMove = (e: MouseEvent) => {
|
||||
const currentPos = { x: e.clientX, y: e.clientY };
|
||||
updateVelocity(currentPos);
|
||||
|
||||
const speed = Math.sqrt(
|
||||
Math.pow(velocity.current.x, 2) + Math.pow(velocity.current.y, 2)
|
||||
);
|
||||
|
||||
cursorX.set(currentPos.x);
|
||||
cursorY.set(currentPos.y);
|
||||
|
||||
if (speed > 0.1) {
|
||||
const currentAngle =
|
||||
Math.atan2(velocity.current.y, velocity.current.x) * (180 / Math.PI) +
|
||||
90;
|
||||
|
||||
let angleDiff = currentAngle - previousAngle.current;
|
||||
if (angleDiff > 180) angleDiff -= 360;
|
||||
if (angleDiff < -180) angleDiff += 360;
|
||||
accumulatedRotation.current += angleDiff;
|
||||
rotation.set(accumulatedRotation.current);
|
||||
previousAngle.current = currentAngle;
|
||||
|
||||
scale.set(0.95);
|
||||
setIsMoving(true);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
scale.set(1);
|
||||
setIsMoving(false);
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
let rafId: number;
|
||||
const throttledMouseMove = (e: MouseEvent) => {
|
||||
if (rafId) return;
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
smoothMouseMove(e);
|
||||
rafId = 0;
|
||||
});
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'none';
|
||||
window.addEventListener('mousemove', throttledMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', throttledMouseMove);
|
||||
document.body.style.cursor = 'auto';
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [cursorX, cursorY, rotation, scale]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: cursorX,
|
||||
top: cursorY,
|
||||
translateX: '-50%',
|
||||
translateY: '-50%',
|
||||
rotate: rotation,
|
||||
scale: scale,
|
||||
zIndex: 100,
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform'
|
||||
}}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30
|
||||
}}
|
||||
>
|
||||
{cursor}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,80 @@
|
|||
'use client';
|
||||
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Icon } from '@/components/icons';
|
||||
|
||||
export function NavMain({
|
||||
items
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarGroupContent className='flex flex-col gap-2'>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
className='bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear'
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconBell,
|
||||
IconChevronsDown,
|
||||
IconCreditCard,
|
||||
IconLogout,
|
||||
IconSparkles
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
export function NavUser({
|
||||
user
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.name}</span>
|
||||
<span className='truncate text-xs'>{user.email}</span>
|
||||
</div>
|
||||
<IconChevronsDown className='ml-auto size-4' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='p-0 font-normal'>
|
||||
<div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.name}</span>
|
||||
<span className='truncate text-xs'>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<IconSparkles className='mr-2 h-4 w-4' />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<IconCircleCheck className='mr-2 h-4 w-4' />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconCreditCard className='mr-2 h-4 w-4' />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconBell className='mr-2 h-4 w-4' />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<IconLogout className='mr-2 h-4 w-4' />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
|
@ -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'>Denta koas</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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
'use client';
|
||||
|
||||
import { useThemeConfig } from '@/components/active-theme';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const DEFAULT_THEMES = [
|
||||
{
|
||||
name: 'Default',
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
value: 'blue'
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
value: 'green'
|
||||
},
|
||||
{
|
||||
name: 'Amber',
|
||||
value: 'amber'
|
||||
}
|
||||
];
|
||||
|
||||
const SCALED_THEMES = [
|
||||
{
|
||||
name: 'Default',
|
||||
value: 'default-scaled'
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
value: 'blue-scaled'
|
||||
}
|
||||
];
|
||||
|
||||
const MONO_THEMES = [
|
||||
{
|
||||
name: 'Mono',
|
||||
value: 'mono-scaled'
|
||||
}
|
||||
];
|
||||
|
||||
export function ThemeSelector() {
|
||||
const { activeTheme, setActiveTheme } = useThemeConfig();
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='theme-selector' className='sr-only'>
|
||||
Theme
|
||||
</Label>
|
||||
<Select value={activeTheme} onValueChange={setActiveTheme}>
|
||||
<SelectTrigger
|
||||
id='theme-selector'
|
||||
className='justify-start *:data-[slot=select-value]:w-12'
|
||||
>
|
||||
<span className='text-muted-foreground hidden sm:block'>
|
||||
Select a theme:
|
||||
</span>
|
||||
<span className='text-muted-foreground block sm:hidden'>Theme</span>
|
||||
<SelectValue placeholder='Select a theme' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='end'>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
{DEFAULT_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Scaled</SelectLabel>
|
||||
{SCALED_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Monospaced</SelectLabel>
|
||||
{MONO_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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
|
||||
};
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -0,0 +1,51 @@
|
|||
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',
|
||||
success:
|
||||
'border-transparent bg-green-500 text-white [a&]:hover:bg-green-600',
|
||||
warning:
|
||||
'border-transparent bg-yellow-400 text-yellow-900 [a&]:hover:bg-yellow-500',
|
||||
info: 'border-transparent bg-blue-500 text-white [a&]:hover:bg-blue-600'
|
||||
}
|
||||
},
|
||||
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 };
|
|
@ -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
|
||||
};
|
|
@ -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 };
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue