diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..7cac7d6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+node_modules
+Dockerfile*
+docker-compose*
+.dockerignore
+.git
+.gitignore
+README.md
+LICENSE
+.vscode
+helm-charts
+.env
+.editorconfig
+.idea
+coverage*
+.DS_Store
\ No newline at end of file
diff --git a/.env b/.env
new file mode 100644
index 0000000..7eb061b
--- /dev/null
+++ b/.env
@@ -0,0 +1,3 @@
+VITE_APP_DOMAIN=http://localhost:3000
+VITE_APP_KEYCLOAK_URL=http://localhost:8090
+VITE_APP_BACKEND_URL=http://localhost:8080
\ No newline at end of file
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 9538d6c..8dc05a2 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -2,42 +2,35 @@ name: NodeJS with Webpack
on:
push:
- branches: ['main', 'release/*']
+ branches: ['**']
pull_request:
branches: ['**']
jobs:
build:
runs-on: ubuntu-latest
-
- strategy:
- matrix:
- node-version: [18.x]
-
steps:
- uses: actions/checkout@v4
- - name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v3
- with:
- node-version: ${{ matrix.node-version }}
+ - uses: oven-sh/setup-bun@v2
- name: Install dependencies
- run: npm install
+ run: bun install --frozen-lockfile
+
+ - name: Codegen
+ run: bun run generate:graphql
- name: Run the tests
- run: npm test
+ run: bun run test
- name: Build
- run: npm run build
+ run: bun run build
env:
CI: false
docker:
-
runs-on: ubuntu-latest
needs: build
-
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -50,9 +43,20 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Make env file for production
+ uses: SpicyPizza/create-envfile@v2.0
+ with:
+ envkey_VITE_APP_DOMAIN: ${{ secrets.VITE_APP_DOMAIN }}
+ envkey_VITE_APP_KEYCLOAK_URL: ${{ secrets.VITE_APP_KEYCLOAK_URL }}
+ envkey_VITE_APP_BACKEND_URL: ${{ secrets.VITE_APP_BACKEND_URL }}
+ directory: .
+ file_name: .env.production
+ fail_on_empty: true
+ sort_keys: false
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
+ platforms: linux/amd64, linux/arm64
tags: xcodeassociated/react-typescript-vite-template:latest
diff --git a/.gitignore b/.gitignore
index de9ee66..775da9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,11 @@ coverage
*.njsproj
*.sln
*.sw?
+
+# AI
+CLAUDE.md
+
+src/graphql/*.ts
+src/graphql/*.js
+
+.env.*
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index c1e18b0..7c44a12 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Use an official Node.js runtime as the base image
-FROM node:18 as build-stage
+FROM oven/bun:1.1-slim as build-stage
LABEL authors="xcodeassociated"
# Set working directory
@@ -9,18 +9,16 @@ WORKDIR /app
COPY package*.json ./
# Install dependencies
-RUN npm install
+RUN bun install --frozen-lockfile
# Copy app source code to the working directory
COPY . .
-## Generate types
-#RUN npm run generate:graphql
+# Generate types
+RUN bun run generate:graphql
-RUN node node_modules/esbuild/install.js
-
-# Build the app
-RUN npm run build
+# Build the prod app
+RUN NODE_ENV=production bun run build
# Use NGINX as the production server
FROM nginx:stable-alpine-slim
diff --git a/bun.lockb b/bun.lockb
index b8c22ce..28b70c3 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/codegen.ts b/codegen.ts
new file mode 100644
index 0000000..6594a7c
--- /dev/null
+++ b/codegen.ts
@@ -0,0 +1,17 @@
+import type { CodegenConfig } from '@graphql-codegen/cli'
+
+const config: CodegenConfig = {
+ schema: './src/graphql/schema.graphql',
+ documents: './src/**/*.graphql',
+ generates: {
+ './src/graphql/generated.ts': {
+ plugins: ['typescript', 'typescript-operations', 'typescript-react-apollo'],
+ config: {
+ withHooks: true,
+ withResultType: true,
+ },
+ },
+ },
+}
+
+export default config
diff --git a/package.json b/package.json
index fcf0c6a..180c0db 100644
--- a/package.json
+++ b/package.json
@@ -9,49 +9,80 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"prod": "serve -s dist",
- "test": "vitest",
+ "test": "vitest --run",
"test:ui": "vitest --ui",
- "coverage": "vitest run --coverage"
+ "coverage": "vitest run --coverage",
+ "generate:graphql": "graphql-codegen --config codegen.ts"
},
"dependencies": {
- "@radix-ui/react-dropdown-menu": "^2.1.1",
- "@radix-ui/react-slot": "^1.1.0",
- "class-variance-authority": "^0.7.0",
+ "@apollo/client": "^3.13.9",
+ "@hookform/resolvers": "^3.9.1",
+ "@radix-ui/react-dialog": "^1.1.3",
+ "@radix-ui/react-dropdown-menu": "^2.1.3",
+ "@radix-ui/react-icons": "^1.3.2",
+ "@radix-ui/react-label": "^2.1.1",
+ "@radix-ui/react-navigation-menu": "^1.2.2",
+ "@radix-ui/react-select": "^2.1.3",
+ "@radix-ui/react-slot": "^1.1.1",
+ "@react-keycloak/web": "^3.4.0",
+ "@reduxjs/toolkit": "^2.8.2",
+ "@tanstack/react-table": "^8.20.6",
+ "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "lucide-react": "^0.408.0",
+ "cmdk": "^1.0.4",
+ "graphql": "^16.10.0",
+ "graphql-codegen": "^0.4.0",
+ "i18next-browser-languagedetector": "^8.0.2",
+ "keycloak-js": "^26.2.0",
+ "lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "tailwind-merge": "^2.4.0",
- "tailwindcss-animate": "^1.0.7"
+ "react-hook-form": "^7.54.2",
+ "react-i18next": "^15.1.3",
+ "react-redux": "^9.2.0",
+ "react-router-dom": "^6.28.1",
+ "redux-persist": "^6.0.0",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "zod": "^3.24.1"
},
"devDependencies": {
- "@testing-library/jest-dom": "^6.4.6",
- "@testing-library/react": "^16.0.0",
+ "@graphql-codegen/cli": "^5.0.7",
+ "@graphql-codegen/client-preset": "^4.5.1",
+ "@graphql-codegen/typescript-operations": "^4.3.1",
+ "@graphql-codegen/typescript-react-apollo": "^4.3.3",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
- "@types/jest": "^29.5.12",
- "@types/node": "^20.14.9",
- "@types/react": "^18.3.3",
- "@types/react-dom": "^18.3.0",
- "@typescript-eslint/eslint-plugin": "^7.13.1",
- "@typescript-eslint/parser": "^7.13.1",
- "@vitejs/plugin-react-swc": "^3.5.0",
- "@vitest/coverage-v8": "^2.0.2",
- "@vitest/ui": "^2.0.2",
- "autoprefixer": "^10.4.19",
- "eslint": "^8.57.0",
+ "@types/jest": "^29.5.14",
+ "@types/keycloak-js": "^3.4.1",
+ "@types/node": "^22.10.2",
+ "@types/react": "^18.3.13",
+ "@types/react-dom": "^18.3.1",
+ "@types/react-i18next": "^8.1.0",
+ "@types/react-router-dom": "^5.3.3",
+ "@types/redux-persist": "^4.3.1",
+ "@typescript-eslint/eslint-plugin": "^8.18.2",
+ "@typescript-eslint/parser": "^8.18.2",
+ "@vitejs/plugin-react-swc": "^3.7.2",
+ "@vitest/coverage-v8": "^2.1.8",
+ "@vitest/ui": "^2.1.8",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
- "eslint-plugin-import": "^2.29.1",
- "eslint-plugin-prettier": "^5.1.3",
- "eslint-plugin-react-hooks": "^4.6.2",
- "eslint-plugin-react-refresh": "^0.4.7",
- "jsdom": "^24.1.0",
- "postcss": "^8.4.39",
- "prettier": "^3.3.2",
- "prettier-plugin-tailwindcss": "^0.6.5",
- "tailwindcss": "^3.4.4",
- "typescript": "^5.2.2",
- "vite": "^5.3.1",
- "vite-tsconfig-paths": "^4.3.2",
- "vitest": "^2.0.2"
+ "eslint-plugin-import": "^2.31.0",
+ "eslint-plugin-prettier": "^5.2.1",
+ "eslint-plugin-react-hooks": "^5.0.0",
+ "eslint-plugin-react-refresh": "^0.4.16",
+ "jsdom": "^25.0.1",
+ "msw": "^2.7.0",
+ "postcss": "^8.4.49",
+ "prettier": "^3.4.2",
+ "prettier-plugin-tailwindcss": "^0.6.9",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.2",
+ "vite": "^5.4.11",
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "^2.1.8"
}
}
diff --git a/public/silent-check-sso.html b/public/silent-check-sso.html
new file mode 100644
index 0000000..f259b4f
--- /dev/null
+++ b/public/silent-check-sso.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 019bc76..3e8d21d 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,17 +1,66 @@
-import '@testing-library/jest-dom'
+import { describe, expect, it, vi, beforeAll } from 'vitest'
+import React, { act } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
-import { expect } from 'vitest'
+import { ThemeProvider } from '@/components/theme-provider.tsx'
+import { Provider } from 'react-redux'
+import { store } from '@/store/store.ts'
+import { BrowserRouter } from 'react-router-dom'
import App from '@/App.tsx'
+import Keycloak, { KeycloakProfile } from 'keycloak-js'
-describe('Page', () => {
- it('renders', async () => {
- render()
+function mockUseKeycloak() {
+ const token = 'A random string that is non zero length'
+ const userProfile: KeycloakProfile = {
+ username: 'test',
+ email: 'test@example.com',
+ firstName: 'Test',
+ lastName: 'User',
+ }
+ const realmAccess = { roles: ['user'] }
+
+ const authClient: Keycloak = {
+ authenticated: true,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ hasRealmRole(_ignored: string) {
+ return true
+ },
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ hasResourceRole(_ignored: string) {
+ return true
+ },
+ idToken: token,
+ profile: userProfile,
+ realm: 'TestRealm',
+ realmAccess,
+ refreshToken: token,
+ token,
+ } as Keycloak
+ return { initialized: true, keycloak: authClient }
+}
+
+describe('App tests', () => {
+ beforeAll(() => {
+ vi.mock('@react-keycloak/web', () => ({ useKeycloak: mockUseKeycloak }))
+ })
+ it('home render with keycloak authorized', async () => {
+ await act(async () =>
+ render(
+
+
+
+
+
+
+
+
+
+ )
+ )
await waitFor(() => {
- expect(screen.getByText(/test-softeno/i)).toBeInTheDocument()
+ // note: menu items shown only after login
+ expect(screen.getByText(/counter/i)).toBeInTheDocument()
+ expect(screen.getByText(/users/i)).toBeInTheDocument()
})
})
- it('some logic', () => {
- expect(1).toEqual(1)
- })
})
diff --git a/src/App.tsx b/src/App.tsx
index 9a2cf53..86e492e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,66 +1,180 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from '/vite.svg'
-import '@/App.css'
-import { Button } from '@/components/ui/button.tsx'
-import { ModeToggle } from '@/components/custom/mode-toggle.tsx'
+import React, { createContext } from 'react'
+import { Counter } from '@/pages/counter/Counter'
+import { useKeycloak } from '@react-keycloak/web'
+import { Navigate, Route, Routes, useNavigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { MenuItem } from '@/components/app/menu-item'
+import { Label } from '@radix-ui/react-menu'
+import { NavigationMenu, NavigationMenuList } from '@/components/ui/navigation-menu'
+import { NavMenuItem } from '@/components/app/nav-item'
+import { MessageSquare, Search } from 'lucide-react'
+import { ModeToggle } from '@/components/custom/mode-toggle'
+import { Input } from '@/components/ui/input'
+import { UserDropdownMenu } from '@/components/app/user-dropdown-menu'
+import { Errors, Unauthorized } from '@/pages/error/errors.tsx'
+import { Home } from '@/pages/home/home'
+import { SideMenu } from '@/components/app/side-menu'
+import { Users } from '@/pages/users/Users'
+import { setLanguage } from '@/locales/i18n'
+import CookieConsent from '@/components/custom/coockie-consent'
+import { LoadingScreenMemo } from '@/components/app/loading-screen'
-function App() {
- const [count, setCount] = useState(0)
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+//@ts-expect-error
+const ProtectedRoute = ({ predicate, redirectPath = '/', children }) => {
+ if (!predicate) {
+ return
+ }
+ return children
+}
+
+export type GlobalSettings = {
+ data?: string
+}
+
+const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault()
+ console.log(`search: ${e.currentTarget.search.value}`)
+ e.currentTarget.reset()
+}
+
+const handleCookieConsentAccept = () => {
+ console.log(`Cookie consent ACCEPTED`)
+}
+
+const handleCookieConsentDecline = () => {
+ console.log(`Cookie consent DECLINED`)
+}
+
+const defaultGlobalSettings: GlobalSettings = { data: 'some-global-data' }
+export const GlobalSettingsContext = createContext(defaultGlobalSettings)
+
+const App: React.FC = () => {
+ const { initialized, keycloak } = useKeycloak()
+ const navigate = useNavigate()
+ const { t } = useTranslation(['main'])
+
+ const menuItems: MenuItem[] = [
+ {
+ key: `${t('menu.home', { ns: ['main'] })}`,
+ route: '/',
+ restricted: false,
+ },
+ {
+ key: `${t('menu.counter', { ns: ['main'] })}`,
+ route: '/counter',
+ restricted: true,
+ },
+ {
+ key: `${t('menu.users', { ns: ['main'] })}`,
+ route: '/users',
+ restricted: true,
+ },
+ ]
+
+ const handleUserDropdownSelect = (item: string) => {
+ console.log(`User dropdown change: ${item}`)
+ switch (item) {
+ case 'profile':
+ console.log('Profile')
+ break
+ case 'settings':
+ console.log('Settings')
+ break
+ case 'lang/english':
+ setLanguage('en')
+ break
+ case 'lang/polish':
+ setLanguage('pl')
+ break
+ default:
+ throw Errors('Unsupported action')
+ }
+ }
+
+ if (!initialized) {
+ return
+ }
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
Vite + React
-
-
-
This is a template application for React and Vite.
-
- There are some test components on the site. Please clear the content and put down your code.
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
{t(`home.section1`, { ns: ['main'] })}
+
{t(`home.section2`, { ns: ['main'] })}
+
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+
-
-
-
+
+
+
)
}
diff --git a/src/components/app/loading-screen.tsx b/src/components/app/loading-screen.tsx
new file mode 100644
index 0000000..2d28a28
--- /dev/null
+++ b/src/components/app/loading-screen.tsx
@@ -0,0 +1,14 @@
+import { Spinner } from '@/components/custom/spinner'
+import React from 'react'
+
+export function LoadingScreen() {
+ return (
+
+ )
+}
+
+export const LoadingScreenMemo = React.memo(LoadingScreen)
diff --git a/src/components/app/menu-item.ts b/src/components/app/menu-item.ts
new file mode 100644
index 0000000..351b281
--- /dev/null
+++ b/src/components/app/menu-item.ts
@@ -0,0 +1,5 @@
+export type MenuItem = {
+ readonly key: string
+ readonly route: string
+ readonly restricted: boolean
+}
diff --git a/src/components/app/nav-item.tsx b/src/components/app/nav-item.tsx
new file mode 100644
index 0000000..5c61992
--- /dev/null
+++ b/src/components/app/nav-item.tsx
@@ -0,0 +1,21 @@
+import { useNavigate } from 'react-router-dom'
+import { useLocation } from 'react-router'
+import { NavigationMenuItem, NavigationMenuLink, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu'
+import { MenuItem } from '@/components/app/menu-item'
+
+export function NavMenuItem({ item }: { item: MenuItem }) {
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ return (
+
+ navigate(item.route)}
+ >
+ {item.key}
+
+
+ )
+}
diff --git a/src/components/app/side-menu.tsx b/src/components/app/side-menu.tsx
new file mode 100644
index 0000000..86c70df
--- /dev/null
+++ b/src/components/app/side-menu.tsx
@@ -0,0 +1,56 @@
+import { MenuItem } from '@/components/app/menu-item'
+import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetDescription, SheetClose } from '@/components/ui/sheet'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Menu } from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+import { useLocation } from 'react-router'
+
+function SideMenuItem({ item }: { item: MenuItem }) {
+ const navigate = useNavigate()
+ const location = useLocation()
+ return (
+
+
+
+ )
+}
+
+export interface SideMenuItemProps {
+ items: MenuItem[]
+ authenticated: boolean
+}
+
+export function SideMenu({ items, authenticated }: SideMenuItemProps) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/app/user-dropdown-menu.tsx b/src/components/app/user-dropdown-menu.tsx
new file mode 100644
index 0000000..9b772c9
--- /dev/null
+++ b/src/components/app/user-dropdown-menu.tsx
@@ -0,0 +1,112 @@
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuPortal,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Button } from '@/components/ui/button'
+import { Languages, LogOut, Settings, User, UserCircle, UserPlus } from 'lucide-react'
+import { useKeycloak } from '@react-keycloak/web'
+import { useTranslation } from 'react-i18next'
+
+export interface UserDropdownMenuProps {
+ handleDropdownSelectFn: (action: string) => void
+}
+
+export function UserDropdownMenu({ handleDropdownSelectFn }: UserDropdownMenuProps) {
+ const { keycloak } = useKeycloak()
+ const { t } = useTranslation(['main'])
+
+ return (
+
+
+
+
+ {keycloak.authenticated ? (
+ <>
+
+ {t('user_dropdown.my_account', { ns: ['main'] })}
+
+ handleDropdownSelectFn('profile')}>
+
+ {t('user_dropdown.profile', { ns: ['main'] })}
+
+ handleDropdownSelectFn('settings')}>
+
+ {t('user_dropdown.settings', { ns: ['main'] })}
+
+
+
+
+
+ {t('user_dropdown.select_language', { ns: ['main'] })}
+
+
+
+ handleDropdownSelectFn('lang/english')}>
+ {t('user_dropdown.en', { ns: ['main'] })}
+
+ handleDropdownSelectFn('lang/polish')}>
+ {t('user_dropdown.pl', { ns: ['main'] })}
+
+
+
+
+
+ {
+ keycloak.logout()
+ }}
+ >
+
+ {t('user_dropdown.logout', { ns: ['main'] })}
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ {t('user_dropdown.select_language', { ns: ['main'] })}
+
+
+
+ handleDropdownSelectFn('lang/english')}>
+ {t('user_dropdown.en', { ns: ['main'] })}
+
+ handleDropdownSelectFn('lang/polish')}>
+ {t('user_dropdown.pl', { ns: ['main'] })}
+
+
+
+
+
+ {
+ keycloak.login()
+ }}
+ >
+
+ {t('user_dropdown.login', { ns: ['main'] })}
+
+ keycloak.register()}>
+
+ {t('user_dropdown.register', { ns: ['main'] })}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/custom/coockie-consent.tsx b/src/components/custom/coockie-consent.tsx
new file mode 100644
index 0000000..f6b9576
--- /dev/null
+++ b/src/components/custom/coockie-consent.tsx
@@ -0,0 +1,99 @@
+import { CookieIcon } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+
+export interface CookieConsentProps {
+ demo?: boolean
+ onAcceptCallback?: () => void
+ onDeclineCallback?: () => void
+}
+
+export default function CookieConsent({ demo, onAcceptCallback, onDeclineCallback }: CookieConsentProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [hide, setHide] = useState(false)
+ const { t } = useTranslation(['main'])
+
+ const accept = () => {
+ setIsOpen(false)
+ document.cookie = 'cookieConsent=true; expires=Fri, 31 Dec 9999 23:59:59 GMT'
+ setTimeout(() => {
+ setHide(true)
+ }, 700)
+ if (onAcceptCallback) {
+ onAcceptCallback()
+ }
+ }
+
+ const decline = () => {
+ setIsOpen(false)
+ setTimeout(() => {
+ setHide(true)
+ }, 700)
+ if (onDeclineCallback) {
+ onDeclineCallback()
+ }
+ }
+
+ useEffect(() => {
+ try {
+ setIsOpen(true)
+ if (document.cookie.includes('cookieConsent=true')) {
+ if (!demo) {
+ setIsOpen(false)
+ setTimeout(() => {
+ setHide(true)
+ }, 700)
+ }
+ }
+ } catch {
+ // console.log("Error: ", e);
+ }
+ }, [demo])
+
+ return (
+
+
+
+
+
{t('cookie_consent.title', { ns: ['main'] })}
+
+
+
+
+ {t('cookie_consent.content', { ns: ['main'] })}
+
+
+
+ {t('cookie_consent.accept_pt1', { ns: ['main'] }) + ' '} "
+ {t('cookie_consent.accept', { ns: ['main'] })}"{' '}
+ {', ' + t('cookie_consent.accept_pt2', { ns: ['main'] })}
+
+
+
+ {t('cookie_consent.learn_more', { ns: ['main'] })}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/custom/data-table.tsx b/src/components/custom/data-table.tsx
new file mode 100644
index 0000000..de19ff5
--- /dev/null
+++ b/src/components/custom/data-table.tsx
@@ -0,0 +1,183 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { Button } from '@/components/ui/button'
+import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ PaginationState,
+ SortingState,
+ useReactTable,
+} from '@tanstack/react-table'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+import { OnChangeFn } from '@tanstack/table-core/src/types'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+import { Table as TTable } from '@tanstack/table-core/build/lib/types'
+
+export interface DataTablePaginationProps
{
+ table: TTable
+ withSelected?: boolean
+}
+
+export function DataTablePagination({ table, withSelected }: DataTablePaginationProps) {
+ return (
+
+
+ {withSelected ? (
+ <>
+ {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
+ selected.
+ >
+ ) : (
+ <>>
+ )}
+
+
+
+
Rows per page
+
+
+
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export interface DataTableProps {
+ columns: ColumnDef[]
+ data: TData[]
+ total: number
+ pagination: PaginationState
+ paginationChangeFn: OnChangeFn
+ sorting?: SortingState
+ sortingChangeFn?: OnChangeFn
+}
+
+export function DataTable({
+ columns,
+ data,
+ total,
+ pagination,
+ paginationChangeFn,
+ sorting,
+ sortingChangeFn,
+}: DataTableProps) {
+ const table = useReactTable({
+ data: data,
+ columns: columns,
+ getCoreRowModel: getCoreRowModel(),
+ onPaginationChange: paginationChangeFn,
+ rowCount: total,
+ state: {
+ pagination,
+ sorting,
+ },
+ manualPagination: true,
+ debugTable: false,
+ onSortingChange: sortingChangeFn,
+ manualSorting: true,
+ })
+
+ return (
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/custom/multiple-selector.tsx b/src/components/custom/multiple-selector.tsx
new file mode 100644
index 0000000..8764506
--- /dev/null
+++ b/src/components/custom/multiple-selector.tsx
@@ -0,0 +1,511 @@
+import { Command as CommandPrimitive, useCommandState } from 'cmdk'
+import { X } from 'lucide-react'
+import * as React from 'react'
+import { forwardRef, useEffect } from 'react'
+
+import { Badge } from '@/components/ui/badge'
+import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
+import { cn } from '@/lib/utils'
+
+export interface Option {
+ value: string
+ label: string
+ disable?: boolean
+ /** fixed option that can't be removed. */
+ fixed?: boolean
+ /** Group the options by providing key. */
+ [key: string]: string | boolean | undefined
+}
+interface GroupOption {
+ [key: string]: Option[]
+}
+
+interface MultipleSelectorProps {
+ value?: Option[]
+ defaultOptions?: Option[]
+ /** manually controlled options */
+ options?: Option[]
+ placeholder?: string
+ /** Loading component. */
+ loadingIndicator?: React.ReactNode
+ /** Empty component. */
+ emptyIndicator?: React.ReactNode
+ /** Debounce time for async search. Only work with `onSearch`. */
+ delay?: number
+ /**
+ * Only work with `onSearch` prop. Trigger search when `onFocus`.
+ * For example, when user click on the input, it will trigger the search to get initial options.
+ **/
+ triggerSearchOnFocus?: boolean
+ /** async search */
+ onSearch?: (value: string) => Promise