generated from fahricansecer/boilerplate-fe
Initial commit
This commit is contained in:
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
src/app/.DS_Store
vendored
Normal file
BIN
src/app/.DS_Store
vendored
Normal file
Binary file not shown.
15
src/app/[locale]/(auth)/layout.tsx
Normal file
15
src/app/[locale]/(auth)/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import Footer from '@/components/layout/footer/footer';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
|
||||
function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Flex minH='100vh' direction='column'>
|
||||
<Box as='main'>{children}</Box>
|
||||
<Footer />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthLayout;
|
||||
231
src/app/[locale]/(auth)/signin/page.tsx
Normal file
231
src/app/[locale]/(auth)/signin/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Input,
|
||||
Link as ChakraLink,
|
||||
Text,
|
||||
ClientOnly,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "@/components/ui/buttons/button";
|
||||
import { Switch } from "@/components/ui/forms/switch";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { useTranslations } from "next-intl";
|
||||
import signInImage from "/public/assets/img/sign-in-image.png";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
import { BiLock } from "react-icons/bi";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { MdMail } from "react-icons/md";
|
||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
|
||||
const schema = yup.object({
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().required(),
|
||||
});
|
||||
|
||||
type SignInForm = yup.InferType<typeof schema>;
|
||||
|
||||
const defaultValues = {
|
||||
email: "test@test.com.ue",
|
||||
password: "test1234",
|
||||
};
|
||||
|
||||
function SignInPage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<SignInForm>({
|
||||
resolver: yupResolver(schema),
|
||||
mode: "onChange",
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: SignInForm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
router.replace("/home");
|
||||
} catch (error) {
|
||||
toaster.error({
|
||||
title: (error as Error).message || "Giriş yaparken hata oluştu!",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Flex
|
||||
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
|
||||
w="100%"
|
||||
maxW="1044px"
|
||||
mx="auto"
|
||||
justifyContent="space-between"
|
||||
mb="30px"
|
||||
pt={{ sm: "100px", md: "0px" }}
|
||||
>
|
||||
<Flex
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
style={{ userSelect: "none" }}
|
||||
w={{ base: "100%", md: "50%", lg: "42%" }}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
w="100%"
|
||||
background="transparent"
|
||||
p="10"
|
||||
mt={{ md: "150px", lg: "80px" }}
|
||||
>
|
||||
<Heading
|
||||
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||
fontSize="32px"
|
||||
mb="10px"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{t("auth.welcome-back")}
|
||||
</Heading>
|
||||
<Text
|
||||
mb="36px"
|
||||
ms="4px"
|
||||
color={{ base: "gray.400", _dark: "white" }}
|
||||
fontWeight="bold"
|
||||
fontSize="14px"
|
||||
>
|
||||
{t("auth.subtitle")}
|
||||
</Text>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("email")}
|
||||
errorText={errors.email?.message}
|
||||
invalid={!!errors.email}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
size="lg"
|
||||
{...register("email")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("password")}
|
||||
errorText={errors.password?.message}
|
||||
invalid={!!errors.password}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||
<PasswordInput
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
placeholder={t("password")}
|
||||
size="lg"
|
||||
{...register("password")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field mb="24px">
|
||||
<Switch colorPalette="teal" label={t("auth.remember-me")}>
|
||||
{t("auth.remember-me")}
|
||||
</Switch>
|
||||
</Field>
|
||||
<Field mb="24px">
|
||||
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
w="100%"
|
||||
h="45px"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: "primary.500",
|
||||
}}
|
||||
_active={{
|
||||
bg: "primary.400",
|
||||
}}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
</Field>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxW="100%"
|
||||
>
|
||||
<Text
|
||||
color={{ base: "gray.400", _dark: "white" }}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{t("auth.dont-have-account")}
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/signup"
|
||||
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||
ms="5px"
|
||||
fontWeight="bold"
|
||||
focusRing="none"
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
overflowX="hidden"
|
||||
h="100%"
|
||||
w="40vw"
|
||||
position="absolute"
|
||||
right="0px"
|
||||
>
|
||||
<Box
|
||||
bgImage={`url(${signInImage.src})`}
|
||||
w="100%"
|
||||
h="100%"
|
||||
bgSize="cover"
|
||||
bgPos="50%"
|
||||
position="absolute"
|
||||
borderBottomLeftRadius="20px"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInPage;
|
||||
166
src/app/[locale]/(auth)/signup/page.tsx
Normal file
166
src/app/[locale]/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { Box, Flex, Input, Link as ChakraLink, Text, ClientOnly } from '@chakra-ui/react';
|
||||
import signUpImage from '/public/assets/img/sign-up-image.png';
|
||||
import { Button } from '@/components/ui/buttons/button';
|
||||
import { Field } from '@/components/ui/forms/field';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { InputGroup } from '@/components/ui/forms/input-group';
|
||||
import { BiLock, BiUser } from 'react-icons/bi';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { MdMail } from 'react-icons/md';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PasswordInput } from '@/components/ui/forms/password-input';
|
||||
import { Skeleton } from '@/components/ui/feedback/skeleton';
|
||||
|
||||
const schema = yup.object({
|
||||
name: yup.string().required(),
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().required(),
|
||||
});
|
||||
|
||||
type SignUpForm = yup.InferType<typeof schema>;
|
||||
|
||||
function SignUpPage() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<SignUpForm>({ resolver: yupResolver(schema), mode: 'onChange' });
|
||||
|
||||
const onSubmit = async (formData: SignUpForm) => {
|
||||
router.replace('/signin');
|
||||
return formData;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
position='absolute'
|
||||
minH={{ base: '70vh', md: '50vh' }}
|
||||
w={{ md: 'calc(100vw - 50px)' }}
|
||||
borderRadius={{ md: '15px' }}
|
||||
left='0'
|
||||
right='0'
|
||||
bgRepeat='no-repeat'
|
||||
overflow='hidden'
|
||||
zIndex='-1'
|
||||
top='0'
|
||||
bgImage={`url(${signUpImage.src})`}
|
||||
bgSize='cover'
|
||||
mx={{ md: 'auto' }}
|
||||
mt={{ md: '14px' }}
|
||||
/>
|
||||
<Flex w='full' h='full' direction='column' alignItems='center' justifyContent='center'>
|
||||
<Text
|
||||
fontSize={{ base: '2xl', md: '3xl', lg: '4xl' }}
|
||||
color='white'
|
||||
fontWeight='bold'
|
||||
mt={{ base: '2rem', md: '4.5rem', '2xl': '6.5rem' }}
|
||||
mb={{ base: '2rem', md: '3rem', '2xl': '4rem' }}
|
||||
>
|
||||
{t('auth.create-an-account-now')}
|
||||
</Text>
|
||||
<Flex
|
||||
direction='column'
|
||||
w={{ base: '100%', md: '445px' }}
|
||||
background='transparent'
|
||||
borderRadius='15px'
|
||||
p='10'
|
||||
mx={{ base: '100px' }}
|
||||
bg='bg.panel'
|
||||
boxShadow='0 20px 27px 0 rgb(0 0 0 / 5%)'
|
||||
mb='8'
|
||||
>
|
||||
<Flex
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
flexDirection='column'
|
||||
alignItems='center'
|
||||
justifyContent='start'
|
||||
w='100%'
|
||||
>
|
||||
<Field mb='24px' label={t('name')} errorText={errors.name?.message} invalid={!!errors.name}>
|
||||
<InputGroup w='full' startElement={<BiUser size='1rem' />}>
|
||||
<Input
|
||||
borderRadius='15px'
|
||||
fontSize='sm'
|
||||
type='text'
|
||||
placeholder={t('name')}
|
||||
size='lg'
|
||||
{...register('name')}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field mb='24px' label={t('email')} errorText={errors.email?.message} invalid={!!errors.email}>
|
||||
<InputGroup w='full' startElement={<MdMail size='1rem' />}>
|
||||
<Input
|
||||
borderRadius='15px'
|
||||
fontSize='sm'
|
||||
type='text'
|
||||
placeholder={t('email')}
|
||||
size='lg'
|
||||
{...register('email')}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field mb='24px' label={t('password')} errorText={errors.password?.message} invalid={!!errors.password}>
|
||||
<InputGroup w='full' startElement={<BiLock size='1rem' />}>
|
||||
<PasswordInput
|
||||
borderRadius='15px'
|
||||
fontSize='sm'
|
||||
placeholder={t('password')}
|
||||
size='lg'
|
||||
{...register('password')}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field mb='24px'>
|
||||
<ClientOnly fallback={<Skeleton height='45px' width='100%' />}>
|
||||
<Button
|
||||
type='submit'
|
||||
bg='primary.400'
|
||||
color='white'
|
||||
fontWeight='bold'
|
||||
w='100%'
|
||||
h='45px'
|
||||
_hover={{
|
||||
bg: 'primary.500',
|
||||
}}
|
||||
_active={{
|
||||
bg: 'primary.400',
|
||||
}}
|
||||
>
|
||||
{t('auth.sign-up')}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
</Field>
|
||||
</Flex>
|
||||
<Flex flexDirection='column' justifyContent='center' alignItems='center' maxW='100%'>
|
||||
<Text color={{ base: 'gray.400', _dark: 'white' }} fontWeight='medium'>
|
||||
{t('auth.already-have-an-account')}
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
color={{ base: 'primary.400', _dark: 'primary.200' }}
|
||||
ml='2'
|
||||
href='/signin'
|
||||
fontWeight='bold'
|
||||
focusRing='none'
|
||||
>
|
||||
{t('auth.sign-in')}
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpPage;
|
||||
5
src/app/[locale]/(error)/[...slug]/page.tsx
Normal file
5
src/app/[locale]/(error)/[...slug]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
7
src/app/[locale]/(site)/about/page.tsx
Normal file
7
src/app/[locale]/(site)/about/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
function AboutPage() {
|
||||
return <div>AboutPage</div>;
|
||||
}
|
||||
|
||||
export default AboutPage;
|
||||
14
src/app/[locale]/(site)/home/page.tsx
Normal file
14
src/app/[locale]/(site)/home/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import HomeCard from "@/components/site/home/home-card";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return {
|
||||
title: `${t("home")} | FCS`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <HomeCard />;
|
||||
}
|
||||
21
src/app/[locale]/(site)/layout.tsx
Normal file
21
src/app/[locale]/(site)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Container, Flex } from '@chakra-ui/react';
|
||||
import Header from '@/components/layout/header/header';
|
||||
import Footer from '@/components/layout/footer/footer';
|
||||
import BackToTop from '@/components/ui/back-to-top';
|
||||
|
||||
function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Flex minH='100vh' direction='column'>
|
||||
<Header />
|
||||
<Container as='main' maxW='8xl' flex='1' py={4}>
|
||||
{children}
|
||||
</Container>
|
||||
<BackToTop />
|
||||
<Footer />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainLayout;
|
||||
BIN
src/app/[locale]/.DS_Store
vendored
Normal file
BIN
src/app/[locale]/.DS_Store
vendored
Normal file
Binary file not shown.
7
src/app/[locale]/global.css
Normal file
7
src/app/[locale]/global.css
Normal file
@@ -0,0 +1,7 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
41
src/app/[locale]/layout.tsx
Normal file
41
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Provider } from '@/components/ui/provider';
|
||||
import { Bricolage_Grotesque } from 'next/font/google';
|
||||
import { hasLocale, NextIntlClientProvider } from 'next-intl';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import { dir } from 'i18next';
|
||||
import './global.css';
|
||||
|
||||
const bricolage = Bricolage_Grotesque({
|
||||
variable: '--font-bricolage',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
|
||||
<head>
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
|
||||
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
|
||||
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
|
||||
<link rel='manifest' href='/favicon/site.webmanifest' />
|
||||
</head>
|
||||
<body className={bricolage.variable}>
|
||||
<NextIntlClientProvider>
|
||||
<Provider>{children}</Provider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
30
src/app/[locale]/not-found.tsx
Normal file
30
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Flex, Text, Button, VStack, Heading } from '@chakra-ui/react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export default async function NotFoundPage() {
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<Flex h='100vh' alignItems='center' justifyContent='center' textAlign='center' px={6}>
|
||||
<VStack spaceY={6}>
|
||||
<Heading
|
||||
as='h1'
|
||||
fontSize={{ base: '5xl', md: '6xl' }}
|
||||
fontWeight='bold'
|
||||
color={{ base: 'primary.600', _dark: 'primary.400' }}
|
||||
>
|
||||
{t('error.404')}
|
||||
</Heading>
|
||||
<Text fontSize={{ base: 'md', md: 'lg' }} color={{ base: 'fg.muted', _dark: 'white' }}>
|
||||
{t('error.not-found')}
|
||||
</Text>
|
||||
<Link href='/home' passHref>
|
||||
<Button size={{ base: 'md', md: 'lg' }} rounded='md'>
|
||||
{t('error.back-to-home')}
|
||||
</Button>
|
||||
</Link>
|
||||
</VStack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
5
src/app/[locale]/page.tsx
Normal file
5
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Page() {
|
||||
redirect('/home');
|
||||
}
|
||||
BIN
src/app/api/.DS_Store
vendored
Normal file
BIN
src/app/api/.DS_Store
vendored
Normal file
Binary file not shown.
97
src/app/api/auth/[...nextauth]/route.ts
Normal file
97
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import baseUrl from "@/config/base-url";
|
||||
import { authService } from "@/lib/api/example/auth/service";
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
|
||||
|
||||
|
||||
function randomToken() {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
||||
|
||||
const handler = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
console.log("credentials", credentials);
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error("Email ve şifre gereklidir.");
|
||||
}
|
||||
|
||||
// Eğer mock mod aktifse backend'e gitme
|
||||
if (isMockMode) {
|
||||
return {
|
||||
id: credentials.email,
|
||||
name: credentials.email.split("@")[0],
|
||||
email: credentials.email,
|
||||
accessToken: randomToken(),
|
||||
refreshToken: randomToken(),
|
||||
};
|
||||
}
|
||||
|
||||
// Normal mod: backend'e istek at
|
||||
const res = await authService.login({
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
});
|
||||
|
||||
console.log("res", res);
|
||||
|
||||
const response = res;
|
||||
|
||||
// Backend returns ApiResponse<TokenResponseDto>
|
||||
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
|
||||
if (!res.success || !response?.data?.accessToken) {
|
||||
throw new Error(response?.message || "Giriş başarısız");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.firstName
|
||||
? `${user.firstName} ${user.lastName || ""}`.trim()
|
||||
: user.email.split("@")[0],
|
||||
email: user.email,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
roles: user.roles || [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }: any) {
|
||||
if (user) {
|
||||
token.accessToken = user.accessToken;
|
||||
token.refreshToken = user.refreshToken;
|
||||
token.id = user.id;
|
||||
token.roles = user.roles;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: any) {
|
||||
session.user.id = token.id;
|
||||
session.user.roles = token.roles;
|
||||
session.accessToken = token.accessToken;
|
||||
session.refreshToken = token.refreshToken;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/signin",
|
||||
error: "/signin",
|
||||
},
|
||||
session: { strategy: "jwt" },
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
BIN
src/components/.DS_Store
vendored
Normal file
BIN
src/components/.DS_Store
vendored
Normal file
Binary file not shown.
154
src/components/auth/login-modal.tsx
Normal file
154
src/components/auth/login-modal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Heading, Input, Text, VStack } from "@chakra-ui/react";
|
||||
import { Button } from "@/components/ui/buttons/button";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/overlays/dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
import { MdMail } from "react-icons/md";
|
||||
import { BiLock } from "react-icons/bi";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
const schema = yup.object({
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().min(6).required(),
|
||||
});
|
||||
|
||||
type LoginForm = yup.InferType<typeof schema>;
|
||||
|
||||
interface LoginModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
|
||||
const t = useTranslations();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: yupResolver(schema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: LoginForm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
toaster.success({
|
||||
title: t("auth.login-success") || "Login successful!",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
toaster.error({
|
||||
title: (error as Error).message || "Login failed!",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={(e) => onOpenChange(e.open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Heading size="lg" color="primary.500">
|
||||
{t("auth.sign-in")}
|
||||
</Heading>
|
||||
</DialogTitle>
|
||||
<DialogCloseTrigger />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
label={t("email")}
|
||||
errorText={errors.email?.message}
|
||||
invalid={!!errors.email}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
{...register("email")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("password")}
|
||||
errorText={errors.password?.message}
|
||||
invalid={!!errors.password}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||
<PasswordInput
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
placeholder={t("password")}
|
||||
{...register("password")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
w="100%"
|
||||
color="white"
|
||||
_hover={{ bg: "primary.500" }}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{t("auth.dont-have-account")}{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
style={{
|
||||
color: "var(--chakra-colors-primary-500)",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
);
|
||||
}
|
||||
71
src/components/layout/footer/footer.tsx
Normal file
71
src/components/layout/footer/footer.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Box, Text, HStack, Link as ChakraLink } from "@chakra-ui/react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Footer() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Box as="footer" bg="bg.muted" mt="auto">
|
||||
<HStack
|
||||
display="flex"
|
||||
justify={{ base: "center", md: "space-between" }}
|
||||
alignContent="center"
|
||||
maxW="8xl"
|
||||
mx="auto"
|
||||
wrap="wrap"
|
||||
px={{ base: 4, md: 8 }}
|
||||
position="relative"
|
||||
minH="16"
|
||||
>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
© {new Date().getFullYear()}
|
||||
<ChakraLink
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://www.fcs.com.tr"
|
||||
color={{ base: "primary.500", _dark: "primary.300" }}
|
||||
focusRing="none"
|
||||
ml="1"
|
||||
>
|
||||
{"FCS"}
|
||||
</ChakraLink>
|
||||
. {t("all-right-reserved")}
|
||||
</Text>
|
||||
|
||||
<HStack spaceX={4}>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/privacy"
|
||||
fontSize="sm"
|
||||
color="fg.muted"
|
||||
focusRing="none"
|
||||
position="relative"
|
||||
textDecor="none"
|
||||
transition="color 0.3s ease-in-out"
|
||||
_hover={{
|
||||
color: { base: "primary.500", _dark: "primary.300" },
|
||||
}}
|
||||
>
|
||||
{t("privacy-policy")}
|
||||
</ChakraLink>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/terms"
|
||||
fontSize="sm"
|
||||
color="fg.muted"
|
||||
focusRing="none"
|
||||
position="relative"
|
||||
textDecor="none"
|
||||
transition="color 0.3s ease-in-out"
|
||||
_hover={{
|
||||
color: { base: "primary.500", _dark: "primary.300" },
|
||||
}}
|
||||
>
|
||||
{t("terms-of-service")}
|
||||
</ChakraLink>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
104
src/components/layout/header/header-link.tsx
Normal file
104
src/components/layout/header/header-link.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Box, Link as ChakraLink, Text } from '@chakra-ui/react';
|
||||
import { NavItem } from '@/config/navigation';
|
||||
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from '@/components/ui/overlays/menu';
|
||||
import { RxChevronDown } from 'react-icons/rx';
|
||||
import { useActiveNavItem } from '@/hooks/useActiveNavItem';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
function HeaderLink({ item }: { item: NavItem }) {
|
||||
const t = useTranslations();
|
||||
const { isActive, isChildActive } = useActiveNavItem(item);
|
||||
const [open, setOpen] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseOpen = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseClose = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
{item.children ? (
|
||||
<Box onMouseEnter={handleMouseOpen} onMouseLeave={handleMouseClose}>
|
||||
<MenuRoot open={open} onOpenChange={(e) => setOpen(e.open)}>
|
||||
<MenuTrigger asChild>
|
||||
<Text
|
||||
display='inline-flex'
|
||||
alignItems='center'
|
||||
gap='1'
|
||||
cursor='pointer'
|
||||
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||
position='relative'
|
||||
fontWeight='semibold'
|
||||
>
|
||||
{t(item.label)}
|
||||
<RxChevronDown
|
||||
style={{ transform: open ? 'rotate(-180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||
/>
|
||||
</Text>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
{item.children.map((child, index) => {
|
||||
const isActiveChild = isChildActive(child.href);
|
||||
|
||||
return (
|
||||
<MenuItem key={index} value={child.href}>
|
||||
<ChakraLink
|
||||
key={index}
|
||||
as={Link}
|
||||
href={child.href}
|
||||
focusRing='none'
|
||||
w='full'
|
||||
color={isActiveChild ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||
position='relative'
|
||||
textDecor='none'
|
||||
fontWeight='semibold'
|
||||
>
|
||||
{t(child.label)}
|
||||
</ChakraLink>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</Box>
|
||||
) : (
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href={item.href}
|
||||
focusRing='none'
|
||||
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||
position='relative'
|
||||
textDecor='none'
|
||||
fontWeight='semibold'
|
||||
_after={{
|
||||
content: "''",
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: '-2px',
|
||||
width: '0%',
|
||||
height: '1.5px',
|
||||
bg: { base: 'primary.500', _dark: 'primary.300' },
|
||||
transition: 'width 0.3s ease-in-out',
|
||||
}}
|
||||
_hover={{
|
||||
_after: {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t(item.label)}
|
||||
</ChakraLink>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeaderLink;
|
||||
229
src/components/layout/header/header.tsx
Normal file
229
src/components/layout/header/header.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
IconButton,
|
||||
Link as ChakraLink,
|
||||
Stack,
|
||||
VStack,
|
||||
Button,
|
||||
MenuItem,
|
||||
ClientOnly,
|
||||
} from "@chakra-ui/react";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ColorModeButton } from "@/components/ui/color-mode";
|
||||
import {
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/overlays/popover";
|
||||
import { RxHamburgerMenu } from "react-icons/rx";
|
||||
import { NAV_ITEMS } from "@/config/navigation";
|
||||
import HeaderLink from "./header-link";
|
||||
import MobileHeaderLink from "./mobile-header-link";
|
||||
import LocaleSwitcher from "@/components/ui/locale-switcher";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
MenuContent,
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/overlays/menu";
|
||||
import { Avatar } from "@/components/ui/data-display/avatar";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { authConfig } from "@/config/auth";
|
||||
import { LoginModal } from "@/components/auth/login-modal";
|
||||
import { LuLogIn } from "react-icons/lu";
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations();
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const isAuthenticated = !!session;
|
||||
const isLoading = status === "loading";
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsSticky(window.scrollY >= 10);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut({ redirect: false });
|
||||
if (authConfig.isAuthRequired) {
|
||||
router.replace("/signin");
|
||||
}
|
||||
};
|
||||
|
||||
// Render user menu or login button based on auth state
|
||||
const renderAuthSection = () => {
|
||||
if (isLoading) {
|
||||
return <Skeleton boxSize="10" rounded="full" />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
||||
<MenuTrigger rounded="full" focusRing="none">
|
||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem onClick={handleLogout} value="sign-out">
|
||||
{t("auth.sign-out")}
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated - show login button
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<LuLogIn />
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// Render mobile auth section
|
||||
const renderMobileAuthSection = () => {
|
||||
if (isLoading) {
|
||||
return <Skeleton height="10" width="full" />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
||||
<Button
|
||||
variant="surface"
|
||||
size="sm"
|
||||
width="full"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t("auth.sign-out")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
width="full"
|
||||
onClick={() => setLoginModalOpen(true)}
|
||||
>
|
||||
<LuLogIn />
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
as="nav"
|
||||
bg={isSticky ? "rgba(255, 255, 255, 0.6)" : "white"}
|
||||
_dark={{
|
||||
bg: isSticky ? "rgba(1, 1, 1, 0.6)" : "black",
|
||||
}}
|
||||
shadow={isSticky ? "sm" : "none"}
|
||||
backdropFilter="blur(12px) saturate(180%)"
|
||||
border="1px solid"
|
||||
borderColor={isSticky ? "whiteAlpha.300" : "transparent"}
|
||||
borderBottomRadius={isSticky ? "xl" : "none"}
|
||||
transition="all 0.4s ease-in-out"
|
||||
px={{ base: 4, md: 8 }}
|
||||
py="3"
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
w="full"
|
||||
>
|
||||
<Flex justify="space-between" align="center" maxW="8xl" mx="auto">
|
||||
{/* Logo */}
|
||||
<HStack>
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/home"
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color={{ base: "primary.500", _dark: "primary.300" }}
|
||||
focusRing="none"
|
||||
textDecor="none"
|
||||
transition="all 0.3s ease-in-out"
|
||||
_hover={{
|
||||
color: { base: "primary.900", _dark: "primary.50" },
|
||||
}}
|
||||
>
|
||||
{"FCS "}
|
||||
</ChakraLink>
|
||||
</HStack>
|
||||
|
||||
{/* DESKTOP NAVIGATION */}
|
||||
<HStack spaceX={4} display={{ base: "none", lg: "flex" }}>
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<HeaderLink key={index} item={item} />
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
<HStack>
|
||||
<ColorModeButton colorPalette="gray" />
|
||||
<Box display={{ base: "none", lg: "inline-flex" }} gap={2}>
|
||||
<LocaleSwitcher />
|
||||
<ClientOnly fallback={<Skeleton boxSize="10" rounded="full" />}>
|
||||
{renderAuthSection()}
|
||||
</ClientOnly>
|
||||
</Box>
|
||||
|
||||
{/* MOBILE NAVIGATION */}
|
||||
<Stack display={{ base: "inline-flex", lg: "none" }}>
|
||||
<ClientOnly fallback={<Skeleton boxSize="9" />}>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger as="span">
|
||||
<IconButton aria-label="Open menu" variant="ghost">
|
||||
<RxHamburgerMenu />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
|
||||
<PopoverBody>
|
||||
<VStack mt="2" align="start" spaceY="2" w="full">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<MobileHeaderLink key={item.label} item={item} />
|
||||
))}
|
||||
<LocaleSwitcher />
|
||||
{renderMobileAuthSection()}
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
</ClientOnly>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Login Modal */}
|
||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/layout/header/mobile-header-link.tsx
Normal file
84
src/components/layout/header/mobile-header-link.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Text, Box, Link as ChakraLink, useDisclosure, VStack } from '@chakra-ui/react';
|
||||
import { RxChevronDown } from 'react-icons/rx';
|
||||
import { NavItem } from '@/config/navigation';
|
||||
import { useActiveNavItem } from '@/hooks/useActiveNavItem';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
function MobileHeaderLink({ item }: { item: NavItem }) {
|
||||
const t = useTranslations();
|
||||
const { isActive, isChildActive } = useActiveNavItem(item);
|
||||
const { open, onToggle } = useDisclosure();
|
||||
|
||||
return (
|
||||
<Box key={item.label} w='full'>
|
||||
{item.children ? (
|
||||
<VStack align='start' w='full' spaceY={0}>
|
||||
<Text
|
||||
onClick={onToggle}
|
||||
display='inline-flex'
|
||||
alignItems='center'
|
||||
gap='1'
|
||||
cursor='pointer'
|
||||
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||
textUnderlineOffset='4px'
|
||||
textUnderlinePosition='from-font'
|
||||
textDecoration={isActive ? 'underline' : 'none'}
|
||||
fontWeight='semibold'
|
||||
fontSize={{ base: 'md', md: 'lg' }}
|
||||
_hover={{
|
||||
color: { base: 'primary.500', _dark: 'primary.300' },
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{t(item.label)}
|
||||
<RxChevronDown
|
||||
style={{ transform: open ? 'rotate(-180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||
/>
|
||||
</Text>
|
||||
{open && item.children && (
|
||||
<VStack align='start' pl='4' pt='1' pb='2' w='full' spaceY={1}>
|
||||
{item.children.map((child, index) => {
|
||||
const isActiveChild = isChildActive(child.href);
|
||||
|
||||
return (
|
||||
<ChakraLink
|
||||
key={index}
|
||||
as={Link}
|
||||
href={child.href}
|
||||
focusRing='none'
|
||||
color={isActiveChild ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||
textUnderlineOffset='4px'
|
||||
textUnderlinePosition='from-font'
|
||||
textDecoration={isActiveChild ? 'underline' : 'none'}
|
||||
fontWeight='semibold'
|
||||
fontSize={{ base: 'md', md: 'lg' }}
|
||||
>
|
||||
{t(child.label)}
|
||||
</ChakraLink>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href={item.href}
|
||||
w='full'
|
||||
focusRing='none'
|
||||
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
|
||||
textUnderlineOffset='4px'
|
||||
textUnderlinePosition='from-font'
|
||||
textDecoration={isActive ? 'underline' : 'none'}
|
||||
fontWeight='semibold'
|
||||
fontSize={{ base: 'md', md: 'lg' }}
|
||||
>
|
||||
{t(item.label)}
|
||||
</ChakraLink>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileHeaderLink;
|
||||
3303
src/components/site/home/home-card.tsx
Normal file
3303
src/components/site/home/home-card.tsx
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/components/ui/.DS_Store
vendored
Normal file
BIN
src/components/ui/.DS_Store
vendored
Normal file
Binary file not shown.
53
src/components/ui/back-to-top.tsx
Normal file
53
src/components/ui/back-to-top.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Icon, IconButton, Presence } from '@chakra-ui/react';
|
||||
import { FiChevronUp } from 'react-icons/fi';
|
||||
|
||||
const BackToTop = () => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsVisible(window.pageYOffset > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Presence
|
||||
unmountOnExit
|
||||
present={isVisible}
|
||||
animationName={{ _open: 'fade-in', _closed: 'fade-out' }}
|
||||
animationDuration='moderate'
|
||||
>
|
||||
<IconButton
|
||||
variant={{ base: 'solid', _dark: 'subtle' }}
|
||||
aria-label='Back to top'
|
||||
position='fixed'
|
||||
bottom='8'
|
||||
right='8'
|
||||
borderRadius='full'
|
||||
size='lg'
|
||||
shadow='lg'
|
||||
zIndex='999'
|
||||
onClick={scrollToTop}
|
||||
>
|
||||
<Icon>
|
||||
<FiChevronUp />
|
||||
</Icon>
|
||||
</IconButton>
|
||||
</Presence>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackToTop;
|
||||
33
src/components/ui/buttons/button.tsx
Normal file
33
src/components/ui/buttons/button.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
|
||||
import { AbsoluteCenter, Button as ChakraButton, Span, Spinner } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ButtonLoadingProps {
|
||||
loading?: boolean;
|
||||
loadingText?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
|
||||
const { loading, disabled, loadingText, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
|
||||
{loading && !loadingText ? (
|
||||
<>
|
||||
<AbsoluteCenter display='inline-flex'>
|
||||
<Spinner size='inherit' color='inherit' />
|
||||
</AbsoluteCenter>
|
||||
<Span opacity={0}>{children}</Span>
|
||||
</>
|
||||
) : loading && loadingText ? (
|
||||
<>
|
||||
<Spinner size='inherit' color='inherit' />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</ChakraButton>
|
||||
);
|
||||
});
|
||||
14
src/components/ui/buttons/close-button.tsx
Normal file
14
src/components/ui/buttons/close-button.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ButtonProps } from '@chakra-ui/react';
|
||||
import { IconButton as ChakraIconButton } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuX } from 'react-icons/lu';
|
||||
|
||||
export type CloseButtonProps = ButtonProps;
|
||||
|
||||
export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>(function CloseButton(props, ref) {
|
||||
return (
|
||||
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}>
|
||||
{props.children ?? <LuX />}
|
||||
</ChakraIconButton>
|
||||
);
|
||||
});
|
||||
11
src/components/ui/buttons/link-button.tsx
Normal file
11
src/components/ui/buttons/link-button.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react';
|
||||
import { createRecipeContext } from '@chakra-ui/react';
|
||||
|
||||
export interface LinkButtonProps extends HTMLChakraProps<'a', RecipeProps<'button'>> {}
|
||||
|
||||
const { withContext } = createRecipeContext({ key: 'button' });
|
||||
|
||||
// Replace "a" with your framework's link component
|
||||
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a');
|
||||
44
src/components/ui/buttons/toggle.tsx
Normal file
44
src/components/ui/buttons/toggle.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import type { ButtonProps } from '@chakra-ui/react';
|
||||
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ToggleProps extends ChakraToggle.RootProps {
|
||||
variant?: keyof typeof variantMap;
|
||||
size?: ButtonProps['size'];
|
||||
}
|
||||
|
||||
const variantMap = {
|
||||
solid: { on: 'solid', off: 'outline' },
|
||||
surface: { on: 'surface', off: 'outline' },
|
||||
subtle: { on: 'subtle', off: 'ghost' },
|
||||
ghost: { on: 'subtle', off: 'ghost' },
|
||||
} as const;
|
||||
|
||||
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(props, ref) {
|
||||
const { variant = 'subtle', size, children, ...rest } = props;
|
||||
const variantConfig = variantMap[variant];
|
||||
|
||||
return (
|
||||
<ChakraToggle.Root asChild {...rest}>
|
||||
<ToggleBaseButton size={size} variant={variantConfig} ref={ref}>
|
||||
{children}
|
||||
</ToggleBaseButton>
|
||||
</ChakraToggle.Root>
|
||||
);
|
||||
});
|
||||
|
||||
interface ToggleBaseButtonProps extends Omit<ButtonProps, 'variant'> {
|
||||
variant: Record<'on' | 'off', ButtonProps['variant']>;
|
||||
}
|
||||
|
||||
const ToggleBaseButton = React.forwardRef<HTMLButtonElement, ToggleBaseButtonProps>(
|
||||
function ToggleBaseButton(props, ref) {
|
||||
const toggle = useToggleContext();
|
||||
const { variant, ...rest } = props;
|
||||
return <Button variant={toggle.pressed ? variant.on : variant.off} ref={ref} {...rest} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const ToggleIndicator = ChakraToggle.Indicator;
|
||||
91
src/components/ui/collections/combobox.tsx
Normal file
91
src/components/ui/collections/combobox.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ComboboxControlProps extends ChakraCombobox.ControlProps {
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlProps>(
|
||||
function ComboboxControl(props, ref) {
|
||||
const { children, clearable, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.Control {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraCombobox.IndicatorGroup>
|
||||
{clearable && <ComboboxClearTrigger />}
|
||||
<ChakraCombobox.Trigger />
|
||||
</ChakraCombobox.IndicatorGroup>
|
||||
</ChakraCombobox.Control>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>(
|
||||
function ComboboxClearTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
|
||||
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
|
||||
</ChakraCombobox.ClearTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface ComboboxContentProps extends ChakraCombobox.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>(
|
||||
function ComboboxContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraCombobox.Positioner>
|
||||
<ChakraCombobox.Content {...rest} ref={ref} />
|
||||
</ChakraCombobox.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>(
|
||||
function ComboboxItem(props, ref) {
|
||||
const { item, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraCombobox.ItemIndicator />
|
||||
</ChakraCombobox.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ComboboxRoot = React.forwardRef<HTMLDivElement, ChakraCombobox.RootProps>(
|
||||
function ComboboxRoot(props, ref) {
|
||||
return <ChakraCombobox.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }} />;
|
||||
},
|
||||
) as ChakraCombobox.RootComponent;
|
||||
|
||||
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGroupProps>(
|
||||
function ComboboxItemGroup(props, ref) {
|
||||
const { children, label, ...rest } = props;
|
||||
return (
|
||||
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
|
||||
<ChakraCombobox.ItemGroupLabel>{label}</ChakraCombobox.ItemGroupLabel>
|
||||
{children}
|
||||
</ChakraCombobox.ItemGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ComboboxLabel = ChakraCombobox.Label;
|
||||
export const ComboboxInput = ChakraCombobox.Input;
|
||||
export const ComboboxEmpty = ChakraCombobox.Empty;
|
||||
export const ComboboxItemText = ChakraCombobox.ItemText;
|
||||
28
src/components/ui/collections/listbox.tsx
Normal file
28
src/components/ui/collections/listbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Listbox as ChakraListbox } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export const ListboxRoot = React.forwardRef<HTMLDivElement, ChakraListbox.RootProps>(function ListboxRoot(props, ref) {
|
||||
return <ChakraListbox.Root {...props} ref={ref} />;
|
||||
}) as ChakraListbox.RootComponent;
|
||||
|
||||
export const ListboxContent = React.forwardRef<HTMLDivElement, ChakraListbox.ContentProps>(
|
||||
function ListboxContent(props, ref) {
|
||||
return <ChakraListbox.Content {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const ListboxItem = React.forwardRef<HTMLDivElement, ChakraListbox.ItemProps>(function ListboxItem(props, ref) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ChakraListbox.Item {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraListbox.ItemIndicator />
|
||||
</ChakraListbox.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export const ListboxLabel = ChakraListbox.Label;
|
||||
export const ListboxItemText = ChakraListbox.ItemText;
|
||||
export const ListboxEmpty = ChakraListbox.Empty;
|
||||
118
src/components/ui/collections/select.tsx
Normal file
118
src/components/ui/collections/select.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import type { CollectionItem } from '@chakra-ui/react';
|
||||
import { Select as ChakraSelect, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '../buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface SelectTriggerProps extends ChakraSelect.ControlProps {
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
|
||||
function SelectTrigger(props, ref) {
|
||||
const { children, clearable, ...rest } = props;
|
||||
return (
|
||||
<ChakraSelect.Control {...rest}>
|
||||
<ChakraSelect.Trigger ref={ref}>{children}</ChakraSelect.Trigger>
|
||||
<ChakraSelect.IndicatorGroup>
|
||||
{clearable && <SelectClearTrigger />}
|
||||
<ChakraSelect.Indicator />
|
||||
</ChakraSelect.IndicatorGroup>
|
||||
</ChakraSelect.Control>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const SelectClearTrigger = React.forwardRef<HTMLButtonElement, ChakraSelect.ClearTriggerProps>(
|
||||
function SelectClearTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraSelect.ClearTrigger asChild {...props} ref={ref}>
|
||||
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
|
||||
</ChakraSelect.ClearTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface SelectContentProps extends ChakraSelect.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(function SelectContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraSelect.Positioner>
|
||||
<ChakraSelect.Content {...rest} ref={ref} />
|
||||
</ChakraSelect.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const SelectItem = React.forwardRef<HTMLDivElement, ChakraSelect.ItemProps>(function SelectItem(props, ref) {
|
||||
const { item, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraSelect.Item key={item.value} item={item} {...rest} ref={ref}>
|
||||
{children}
|
||||
<ChakraSelect.ItemIndicator />
|
||||
</ChakraSelect.Item>
|
||||
);
|
||||
});
|
||||
|
||||
interface SelectValueTextProps extends Omit<ChakraSelect.ValueTextProps, 'children'> {
|
||||
children?(items: CollectionItem[]): React.ReactNode;
|
||||
}
|
||||
|
||||
export const SelectValueText = React.forwardRef<HTMLSpanElement, SelectValueTextProps>(
|
||||
function SelectValueText(props, ref) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ChakraSelect.ValueText {...rest} ref={ref}>
|
||||
<ChakraSelect.Context>
|
||||
{(select) => {
|
||||
const items = select.selectedItems;
|
||||
if (items.length === 0) return props.placeholder;
|
||||
if (children) return children(items);
|
||||
if (items.length === 1) return select.collection.stringifyItem(items[0]);
|
||||
return `${items.length} selected`;
|
||||
}}
|
||||
</ChakraSelect.Context>
|
||||
</ChakraSelect.ValueText>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const SelectRoot = React.forwardRef<HTMLDivElement, ChakraSelect.RootProps>(function SelectRoot(props, ref) {
|
||||
return (
|
||||
<ChakraSelect.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }}>
|
||||
{props.asChild ? (
|
||||
props.children
|
||||
) : (
|
||||
<>
|
||||
<ChakraSelect.HiddenSelect />
|
||||
{props.children}
|
||||
</>
|
||||
)}
|
||||
</ChakraSelect.Root>
|
||||
);
|
||||
}) as ChakraSelect.RootComponent;
|
||||
|
||||
interface SelectItemGroupProps extends ChakraSelect.ItemGroupProps {
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SelectItemGroup = React.forwardRef<HTMLDivElement, SelectItemGroupProps>(
|
||||
function SelectItemGroup(props, ref) {
|
||||
const { children, label, ...rest } = props;
|
||||
return (
|
||||
<ChakraSelect.ItemGroup {...rest} ref={ref}>
|
||||
<ChakraSelect.ItemGroupLabel>{label}</ChakraSelect.ItemGroupLabel>
|
||||
{children}
|
||||
</ChakraSelect.ItemGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const SelectLabel = ChakraSelect.Label;
|
||||
export const SelectItemText = ChakraSelect.ItemText;
|
||||
60
src/components/ui/collections/treeview.tsx
Normal file
60
src/components/ui/collections/treeview.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { TreeView as ChakraTreeView } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export const TreeViewRoot = React.forwardRef<HTMLDivElement, ChakraTreeView.RootProps>(
|
||||
function TreeViewRoot(props, ref) {
|
||||
return <ChakraTreeView.Root {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
interface TreeViewTreeProps extends ChakraTreeView.TreeProps {}
|
||||
|
||||
export const TreeViewTree = React.forwardRef<HTMLDivElement, TreeViewTreeProps>(function TreeViewTree(props, ref) {
|
||||
const { ...rest } = props;
|
||||
return <ChakraTreeView.Tree {...rest} ref={ref} />;
|
||||
});
|
||||
|
||||
export const TreeViewBranch = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchProps>(
|
||||
function TreeViewBranch(props, ref) {
|
||||
return <ChakraTreeView.Branch {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const TreeViewBranchControl = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchControlProps>(
|
||||
function TreeViewBranchControl(props, ref) {
|
||||
return <ChakraTreeView.BranchControl {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const TreeViewItem = React.forwardRef<HTMLDivElement, ChakraTreeView.ItemProps>(
|
||||
function TreeViewItem(props, ref) {
|
||||
return <ChakraTreeView.Item {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const TreeViewLabel = ChakraTreeView.Label;
|
||||
export const TreeViewBranchIndicator = ChakraTreeView.BranchIndicator;
|
||||
export const TreeViewBranchText = ChakraTreeView.BranchText;
|
||||
export const TreeViewBranchContent = ChakraTreeView.BranchContent;
|
||||
export const TreeViewBranchIndentGuide = ChakraTreeView.BranchIndentGuide;
|
||||
export const TreeViewItemText = ChakraTreeView.ItemText;
|
||||
export const TreeViewNode = ChakraTreeView.Node;
|
||||
export const TreeViewNodeProvider = ChakraTreeView.NodeProvider;
|
||||
|
||||
export const TreeView = {
|
||||
Root: TreeViewRoot,
|
||||
Label: TreeViewLabel,
|
||||
Tree: TreeViewTree,
|
||||
Branch: TreeViewBranch,
|
||||
BranchControl: TreeViewBranchControl,
|
||||
BranchIndicator: TreeViewBranchIndicator,
|
||||
BranchText: TreeViewBranchText,
|
||||
BranchContent: TreeViewBranchContent,
|
||||
BranchIndentGuide: TreeViewBranchIndentGuide,
|
||||
Item: TreeViewItem,
|
||||
ItemText: TreeViewItemText,
|
||||
Node: TreeViewNode,
|
||||
NodeProvider: TreeViewNodeProvider,
|
||||
};
|
||||
108
src/components/ui/color-mode.tsx
Normal file
108
src/components/ui/color-mode.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import type { IconButtonProps, SpanProps } from '@chakra-ui/react';
|
||||
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react';
|
||||
import { ThemeProvider, useTheme } from 'next-themes';
|
||||
import type { ThemeProviderProps } from 'next-themes';
|
||||
import * as React from 'react';
|
||||
import { LuMoon, LuSun } from 'react-icons/lu';
|
||||
|
||||
export interface ColorModeProviderProps extends ThemeProviderProps {}
|
||||
|
||||
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||
return <ThemeProvider attribute='class' disableTransitionOnChange {...props} />;
|
||||
}
|
||||
|
||||
export type ColorMode = 'light' | 'dark';
|
||||
|
||||
export interface UseColorModeReturn {
|
||||
colorMode: ColorMode;
|
||||
setColorMode: (colorMode: ColorMode) => void;
|
||||
toggleColorMode: () => void;
|
||||
}
|
||||
|
||||
export function useColorMode(): UseColorModeReturn {
|
||||
const { resolvedTheme, setTheme, forcedTheme } = useTheme();
|
||||
const colorMode = forcedTheme || resolvedTheme;
|
||||
const toggleColorMode = () => {
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
return {
|
||||
colorMode: colorMode as ColorMode,
|
||||
setColorMode: setTheme,
|
||||
toggleColorMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function useColorModeValue<T>(light: T, dark: T) {
|
||||
const { colorMode } = useColorMode();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) {
|
||||
return light;
|
||||
}
|
||||
return colorMode === 'dark' ? dark : light;
|
||||
}
|
||||
|
||||
export function ColorModeIcon() {
|
||||
const { colorMode } = useColorMode();
|
||||
return colorMode === 'dark' ? <LuMoon /> : <LuSun />;
|
||||
}
|
||||
|
||||
interface ColorModeButtonProps extends Omit<IconButtonProps, 'aria-label'> {}
|
||||
|
||||
export const ColorModeButton = React.forwardRef<HTMLButtonElement, ColorModeButtonProps>(
|
||||
function ColorModeButton(props, ref) {
|
||||
const { toggleColorMode } = useColorMode();
|
||||
return (
|
||||
<ClientOnly fallback={<Skeleton boxSize='9' />}>
|
||||
<IconButton
|
||||
onClick={toggleColorMode}
|
||||
variant='ghost'
|
||||
aria-label='Toggle color mode'
|
||||
size='sm'
|
||||
ref={ref}
|
||||
{...props}
|
||||
css={{
|
||||
_icon: {
|
||||
width: '5',
|
||||
height: '5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ColorModeIcon />
|
||||
</IconButton>
|
||||
</ClientOnly>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(function LightMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color='fg'
|
||||
display='contents'
|
||||
className='chakra-theme light'
|
||||
colorPalette='gray'
|
||||
colorScheme='light'
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(function DarkMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color='fg'
|
||||
display='contents'
|
||||
className='chakra-theme dark'
|
||||
colorPalette='gray'
|
||||
colorScheme='dark'
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
26
src/components/ui/data-display/avatar.tsx
Normal file
26
src/components/ui/data-display/avatar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Avatar as ChakraAvatar, AvatarGroup as ChakraAvatarGroup } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
type ImageProps = React.ImgHTMLAttributes<HTMLImageElement>;
|
||||
|
||||
export interface AvatarProps extends ChakraAvatar.RootProps {
|
||||
name?: string;
|
||||
src?: string;
|
||||
srcSet?: string;
|
||||
loading?: ImageProps['loading'];
|
||||
icon?: React.ReactElement;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(function Avatar(props, ref) {
|
||||
const { name, src, srcSet, loading, icon, fallback, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraAvatar.Root ref={ref} {...rest}>
|
||||
<ChakraAvatar.Fallback name={name}>{icon || fallback}</ChakraAvatar.Fallback>
|
||||
<ChakraAvatar.Image src={src} srcSet={srcSet} loading={loading} />
|
||||
{children}
|
||||
</ChakraAvatar.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export const AvatarGroup = ChakraAvatarGroup;
|
||||
79
src/components/ui/data-display/clipboard.tsx
Normal file
79
src/components/ui/data-display/clipboard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ButtonProps, InputProps } from '@chakra-ui/react';
|
||||
import { Button, Clipboard as ChakraClipboard, IconButton, Input } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuCheck, LuClipboard, LuLink } from 'react-icons/lu';
|
||||
|
||||
const ClipboardIcon = React.forwardRef<HTMLDivElement, ChakraClipboard.IndicatorProps>(
|
||||
function ClipboardIcon(props, ref) {
|
||||
return (
|
||||
<ChakraClipboard.Indicator copied={<LuCheck />} {...props} ref={ref}>
|
||||
<LuClipboard />
|
||||
</ChakraClipboard.Indicator>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const ClipboardCopyText = React.forwardRef<HTMLDivElement, ChakraClipboard.IndicatorProps>(
|
||||
function ClipboardCopyText(props, ref) {
|
||||
return (
|
||||
<ChakraClipboard.Indicator copied='Copied' {...props} ref={ref}>
|
||||
Copy
|
||||
</ChakraClipboard.Indicator>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ClipboardLabel = React.forwardRef<HTMLLabelElement, ChakraClipboard.LabelProps>(
|
||||
function ClipboardLabel(props, ref) {
|
||||
return (
|
||||
<ChakraClipboard.Label textStyle='sm' fontWeight='medium' display='inline-block' mb='1' {...props} ref={ref} />
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ClipboardButton = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardButton(props, ref) {
|
||||
return (
|
||||
<ChakraClipboard.Trigger asChild>
|
||||
<Button ref={ref} size='sm' variant='surface' {...props}>
|
||||
<ClipboardIcon />
|
||||
<ClipboardCopyText />
|
||||
</Button>
|
||||
</ChakraClipboard.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
export const ClipboardLink = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardLink(props, ref) {
|
||||
return (
|
||||
<ChakraClipboard.Trigger asChild>
|
||||
<Button unstyled variant='plain' size='xs' display='inline-flex' alignItems='center' gap='2' ref={ref} {...props}>
|
||||
<LuLink />
|
||||
<ClipboardCopyText />
|
||||
</Button>
|
||||
</ChakraClipboard.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
export const ClipboardIconButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function ClipboardIconButton(props, ref) {
|
||||
return (
|
||||
<ChakraClipboard.Trigger asChild>
|
||||
<IconButton ref={ref} size='xs' variant='subtle' {...props}>
|
||||
<ClipboardIcon />
|
||||
<ClipboardCopyText srOnly />
|
||||
</IconButton>
|
||||
</ChakraClipboard.Trigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ClipboardInput = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
function ClipboardInputElement(props, ref) {
|
||||
return (
|
||||
<ChakraClipboard.Input asChild>
|
||||
<Input ref={ref} {...props} />
|
||||
</ChakraClipboard.Input>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ClipboardRoot = ChakraClipboard.Root;
|
||||
26
src/components/ui/data-display/data-list.tsx
Normal file
26
src/components/ui/data-display/data-list.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DataList as ChakraDataList } from '@chakra-ui/react';
|
||||
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
|
||||
import * as React from 'react';
|
||||
|
||||
export const DataListRoot = ChakraDataList.Root;
|
||||
|
||||
interface ItemProps extends ChakraDataList.ItemProps {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
info?: React.ReactNode;
|
||||
grow?: boolean;
|
||||
}
|
||||
|
||||
export const DataListItem = React.forwardRef<HTMLDivElement, ItemProps>(function DataListItem(props, ref) {
|
||||
const { label, info, value, children, grow, ...rest } = props;
|
||||
return (
|
||||
<ChakraDataList.Item ref={ref} {...rest}>
|
||||
<ChakraDataList.ItemLabel flex={grow ? '1' : undefined}>
|
||||
{label}
|
||||
{info && <InfoTip>{info}</InfoTip>}
|
||||
</ChakraDataList.ItemLabel>
|
||||
<ChakraDataList.ItemValue flex={grow ? '1' : undefined}>{value}</ChakraDataList.ItemValue>
|
||||
{children}
|
||||
</ChakraDataList.Item>
|
||||
);
|
||||
});
|
||||
20
src/components/ui/data-display/qr-code.tsx
Normal file
20
src/components/ui/data-display/qr-code.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { QrCode as ChakraQrCode } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface QrCodeProps extends Omit<ChakraQrCode.RootProps, 'fill' | 'overlay'> {
|
||||
fill?: string;
|
||||
overlay?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const QrCode = React.forwardRef<HTMLDivElement, QrCodeProps>(function QrCode(props, ref) {
|
||||
const { children, fill, overlay, ...rest } = props;
|
||||
return (
|
||||
<ChakraQrCode.Root ref={ref} {...rest}>
|
||||
<ChakraQrCode.Frame style={{ fill }}>
|
||||
<ChakraQrCode.Pattern />
|
||||
</ChakraQrCode.Frame>
|
||||
{children}
|
||||
{overlay && <ChakraQrCode.Overlay>{overlay}</ChakraQrCode.Overlay>}
|
||||
</ChakraQrCode.Root>
|
||||
);
|
||||
});
|
||||
53
src/components/ui/data-display/stat.tsx
Normal file
53
src/components/ui/data-display/stat.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Badge, type BadgeProps, Stat as ChakraStat, FormatNumber } from '@chakra-ui/react';
|
||||
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
|
||||
import * as React from 'react';
|
||||
|
||||
interface StatLabelProps extends ChakraStat.LabelProps {
|
||||
info?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StatLabel = React.forwardRef<HTMLDivElement, StatLabelProps>(function StatLabel(props, ref) {
|
||||
const { info, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraStat.Label {...rest} ref={ref}>
|
||||
{children}
|
||||
{info && <InfoTip>{info}</InfoTip>}
|
||||
</ChakraStat.Label>
|
||||
);
|
||||
});
|
||||
|
||||
interface StatValueTextProps extends ChakraStat.ValueTextProps {
|
||||
value?: number;
|
||||
formatOptions?: Intl.NumberFormatOptions;
|
||||
}
|
||||
|
||||
export const StatValueText = React.forwardRef<HTMLDivElement, StatValueTextProps>(function StatValueText(props, ref) {
|
||||
const { value, formatOptions, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraStat.ValueText {...rest} ref={ref}>
|
||||
{children || (value != null && <FormatNumber value={value} {...formatOptions} />)}
|
||||
</ChakraStat.ValueText>
|
||||
);
|
||||
});
|
||||
|
||||
export const StatUpTrend = React.forwardRef<HTMLDivElement, BadgeProps>(function StatUpTrend(props, ref) {
|
||||
return (
|
||||
<Badge colorPalette='green' gap='0' {...props} ref={ref}>
|
||||
<ChakraStat.UpIndicator />
|
||||
{props.children}
|
||||
</Badge>
|
||||
);
|
||||
});
|
||||
|
||||
export const StatDownTrend = React.forwardRef<HTMLDivElement, BadgeProps>(function StatDownTrend(props, ref) {
|
||||
return (
|
||||
<Badge colorPalette='red' gap='0' {...props} ref={ref}>
|
||||
<ChakraStat.DownIndicator />
|
||||
{props.children}
|
||||
</Badge>
|
||||
);
|
||||
});
|
||||
|
||||
export const StatRoot = ChakraStat.Root;
|
||||
export const StatHelpText = ChakraStat.HelpText;
|
||||
export const StatValueUnit = ChakraStat.ValueUnit;
|
||||
26
src/components/ui/data-display/tag.tsx
Normal file
26
src/components/ui/data-display/tag.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Tag as ChakraTag } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface TagProps extends ChakraTag.RootProps {
|
||||
startElement?: React.ReactNode;
|
||||
endElement?: React.ReactNode;
|
||||
onClose?: VoidFunction;
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(function Tag(props, ref) {
|
||||
const { startElement, endElement, onClose, closable = !!onClose, children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ChakraTag.Root ref={ref} {...rest}>
|
||||
{startElement && <ChakraTag.StartElement>{startElement}</ChakraTag.StartElement>}
|
||||
<ChakraTag.Label>{children}</ChakraTag.Label>
|
||||
{endElement && <ChakraTag.EndElement>{endElement}</ChakraTag.EndElement>}
|
||||
{closable && (
|
||||
<ChakraTag.EndElement>
|
||||
<ChakraTag.CloseTrigger onClick={onClose} />
|
||||
</ChakraTag.EndElement>
|
||||
)}
|
||||
</ChakraTag.Root>
|
||||
);
|
||||
});
|
||||
25
src/components/ui/data-display/timeline.tsx
Normal file
25
src/components/ui/data-display/timeline.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Timeline as ChakraTimeline } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface TimelineConnectorProps extends ChakraTimeline.IndicatorProps {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TimelineConnector = React.forwardRef<HTMLDivElement, TimelineConnectorProps>(function TimelineConnector(
|
||||
{ icon, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<ChakraTimeline.Connector ref={ref}>
|
||||
<ChakraTimeline.Separator />
|
||||
<ChakraTimeline.Indicator {...props}>{icon}</ChakraTimeline.Indicator>
|
||||
</ChakraTimeline.Connector>
|
||||
);
|
||||
});
|
||||
|
||||
export const TimelineRoot = ChakraTimeline.Root;
|
||||
export const TimelineContent = ChakraTimeline.Content;
|
||||
export const TimelineItem = ChakraTimeline.Item;
|
||||
export const TimelineIndicator = ChakraTimeline.Indicator;
|
||||
export const TimelineTitle = ChakraTimeline.Title;
|
||||
export const TimelineDescription = ChakraTimeline.Description;
|
||||
45
src/components/ui/disclosure/accordion.tsx
Normal file
45
src/components/ui/disclosure/accordion.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Accordion, HStack } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuChevronDown } from 'react-icons/lu';
|
||||
|
||||
interface AccordionItemTriggerProps extends Accordion.ItemTriggerProps {
|
||||
indicatorPlacement?: 'start' | 'end';
|
||||
}
|
||||
|
||||
export const AccordionItemTrigger = React.forwardRef<HTMLButtonElement, AccordionItemTriggerProps>(
|
||||
function AccordionItemTrigger(props, ref) {
|
||||
const { children, indicatorPlacement = 'end', ...rest } = props;
|
||||
return (
|
||||
<Accordion.ItemTrigger {...rest} ref={ref}>
|
||||
{indicatorPlacement === 'start' && (
|
||||
<Accordion.ItemIndicator rotate={{ base: '-90deg', _open: '0deg' }}>
|
||||
<LuChevronDown />
|
||||
</Accordion.ItemIndicator>
|
||||
)}
|
||||
<HStack gap='4' flex='1' textAlign='start' width='full'>
|
||||
{children}
|
||||
</HStack>
|
||||
{indicatorPlacement === 'end' && (
|
||||
<Accordion.ItemIndicator>
|
||||
<LuChevronDown />
|
||||
</Accordion.ItemIndicator>
|
||||
)}
|
||||
</Accordion.ItemTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface AccordionItemContentProps extends Accordion.ItemContentProps {}
|
||||
|
||||
export const AccordionItemContent = React.forwardRef<HTMLDivElement, AccordionItemContentProps>(
|
||||
function AccordionItemContent(props, ref) {
|
||||
return (
|
||||
<Accordion.ItemContent>
|
||||
<Accordion.ItemBody {...props} ref={ref} />
|
||||
</Accordion.ItemContent>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const AccordionRoot = Accordion.Root;
|
||||
export const AccordionItem = Accordion.Item;
|
||||
35
src/components/ui/disclosure/breadcrumb.tsx
Normal file
35
src/components/ui/disclosure/breadcrumb.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Breadcrumb, type SystemStyleObject } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface BreadcrumbRootProps extends Breadcrumb.RootProps {
|
||||
separator?: React.ReactNode;
|
||||
separatorGap?: SystemStyleObject['gap'];
|
||||
}
|
||||
|
||||
export const BreadcrumbRoot = React.forwardRef<HTMLDivElement, BreadcrumbRootProps>(
|
||||
function BreadcrumbRoot(props, ref) {
|
||||
const { separator, separatorGap, children, ...rest } = props;
|
||||
|
||||
const validChildren = React.Children.toArray(children).filter(React.isValidElement);
|
||||
|
||||
return (
|
||||
<Breadcrumb.Root ref={ref} {...rest}>
|
||||
<Breadcrumb.List gap={separatorGap}>
|
||||
{validChildren.map((child, index) => {
|
||||
const last = index === validChildren.length - 1;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Breadcrumb.Item>{child}</Breadcrumb.Item>
|
||||
{!last && <Breadcrumb.Separator>{separator}</Breadcrumb.Separator>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const BreadcrumbLink = Breadcrumb.Link;
|
||||
export const BreadcrumbCurrentLink = Breadcrumb.CurrentLink;
|
||||
export const BreadcrumbEllipsis = Breadcrumb.Ellipsis;
|
||||
182
src/components/ui/disclosure/pagination.tsx
Normal file
182
src/components/ui/disclosure/pagination.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import type { ButtonProps, TextProps } from '@chakra-ui/react';
|
||||
import {
|
||||
Button,
|
||||
Pagination as ChakraPagination,
|
||||
IconButton,
|
||||
Text,
|
||||
createContext,
|
||||
usePaginationContext,
|
||||
} from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { HiChevronLeft, HiChevronRight, HiMiniEllipsisHorizontal } from 'react-icons/hi2';
|
||||
import { LinkButton } from '@/components/ui/buttons/link-button';
|
||||
|
||||
interface ButtonVariantMap {
|
||||
current: ButtonProps['variant'];
|
||||
default: ButtonProps['variant'];
|
||||
ellipsis: ButtonProps['variant'];
|
||||
}
|
||||
|
||||
type PaginationVariant = 'outline' | 'solid' | 'subtle';
|
||||
|
||||
interface ButtonVariantContext {
|
||||
size: ButtonProps['size'];
|
||||
variantMap: ButtonVariantMap;
|
||||
getHref?: (page: number) => string;
|
||||
}
|
||||
|
||||
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
|
||||
name: 'RootPropsProvider',
|
||||
});
|
||||
|
||||
export interface PaginationRootProps extends Omit<ChakraPagination.RootProps, 'type'> {
|
||||
size?: ButtonProps['size'];
|
||||
variant?: PaginationVariant;
|
||||
getHref?: (page: number) => string;
|
||||
}
|
||||
|
||||
const variantMap: Record<PaginationVariant, ButtonVariantMap> = {
|
||||
outline: { default: 'ghost', ellipsis: 'plain', current: 'outline' },
|
||||
solid: { default: 'outline', ellipsis: 'outline', current: 'solid' },
|
||||
subtle: { default: 'ghost', ellipsis: 'plain', current: 'subtle' },
|
||||
};
|
||||
|
||||
export const PaginationRoot = React.forwardRef<HTMLDivElement, PaginationRootProps>(
|
||||
function PaginationRoot(props, ref) {
|
||||
const { size = 'sm', variant = 'outline', getHref, ...rest } = props;
|
||||
return (
|
||||
<RootPropsProvider value={{ size, variantMap: variantMap[variant], getHref }}>
|
||||
<ChakraPagination.Root ref={ref} type={getHref ? 'link' : 'button'} {...rest} />
|
||||
</RootPropsProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PaginationEllipsis = React.forwardRef<HTMLDivElement, ChakraPagination.EllipsisProps>(
|
||||
function PaginationEllipsis(props, ref) {
|
||||
const { size, variantMap } = useRootProps();
|
||||
return (
|
||||
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
|
||||
<Button as='span' variant={variantMap.ellipsis} size={size}>
|
||||
<HiMiniEllipsisHorizontal />
|
||||
</Button>
|
||||
</ChakraPagination.Ellipsis>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PaginationItem = React.forwardRef<HTMLButtonElement, ChakraPagination.ItemProps>(
|
||||
function PaginationItem(props, ref) {
|
||||
const { page } = usePaginationContext();
|
||||
const { size, variantMap, getHref } = useRootProps();
|
||||
|
||||
const current = page === props.value;
|
||||
const variant = current ? variantMap.current : variantMap.default;
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
|
||||
{props.value}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.Item ref={ref} {...props} asChild>
|
||||
<Button variant={variant} size={size}>
|
||||
{props.value}
|
||||
</Button>
|
||||
</ChakraPagination.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PaginationPrevTrigger = React.forwardRef<HTMLButtonElement, ChakraPagination.PrevTriggerProps>(
|
||||
function PaginationPrevTrigger(props, ref) {
|
||||
const { size, variantMap, getHref } = useRootProps();
|
||||
const { previousPage } = usePaginationContext();
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton
|
||||
href={previousPage != null ? getHref(previousPage) : undefined}
|
||||
variant={variantMap.default}
|
||||
size={size}
|
||||
>
|
||||
<HiChevronLeft />
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
|
||||
<IconButton variant={variantMap.default} size={size}>
|
||||
<HiChevronLeft />
|
||||
</IconButton>
|
||||
</ChakraPagination.PrevTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PaginationNextTrigger = React.forwardRef<HTMLButtonElement, ChakraPagination.NextTriggerProps>(
|
||||
function PaginationNextTrigger(props, ref) {
|
||||
const { size, variantMap, getHref } = useRootProps();
|
||||
const { nextPage } = usePaginationContext();
|
||||
|
||||
if (getHref) {
|
||||
return (
|
||||
<LinkButton href={nextPage != null ? getHref(nextPage) : undefined} variant={variantMap.default} size={size}>
|
||||
<HiChevronRight />
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraPagination.NextTrigger ref={ref} asChild {...props}>
|
||||
<IconButton variant={variantMap.default} size={size}>
|
||||
<HiChevronRight />
|
||||
</IconButton>
|
||||
</ChakraPagination.NextTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
|
||||
return (
|
||||
<ChakraPagination.Context>
|
||||
{({ pages }) =>
|
||||
pages.map((page, index) => {
|
||||
return page.type === 'ellipsis' ? (
|
||||
<PaginationEllipsis key={index} index={index} {...props} />
|
||||
) : (
|
||||
<PaginationItem key={index} type='page' value={page.value} {...props} />
|
||||
);
|
||||
})
|
||||
}
|
||||
</ChakraPagination.Context>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageTextProps extends TextProps {
|
||||
format?: 'short' | 'compact' | 'long';
|
||||
}
|
||||
|
||||
export const PaginationPageText = React.forwardRef<HTMLParagraphElement, PageTextProps>(
|
||||
function PaginationPageText(props, ref) {
|
||||
const { format = 'compact', ...rest } = props;
|
||||
const { page, totalPages, pageRange, count } = usePaginationContext();
|
||||
const content = React.useMemo(() => {
|
||||
if (format === 'short') return `${page} / ${totalPages}`;
|
||||
if (format === 'compact') return `${page} of ${totalPages}`;
|
||||
return `${pageRange.start + 1} - ${Math.min(pageRange.end, count)} of ${count}`;
|
||||
}, [format, page, totalPages, pageRange, count]);
|
||||
|
||||
return (
|
||||
<Text fontWeight='medium' ref={ref} {...rest}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
);
|
||||
73
src/components/ui/disclosure/steps.tsx
Normal file
73
src/components/ui/disclosure/steps.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Box, Steps as ChakraSteps } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuCheck } from 'react-icons/lu';
|
||||
|
||||
interface StepInfoProps {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface StepsItemProps extends Omit<ChakraSteps.ItemProps, 'title'>, StepInfoProps {
|
||||
completedIcon?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
disableTrigger?: boolean;
|
||||
}
|
||||
|
||||
export const StepsItem = React.forwardRef<HTMLDivElement, StepsItemProps>(function StepsItem(props, ref) {
|
||||
const { title, description, completedIcon, icon, disableTrigger, ...rest } = props;
|
||||
return (
|
||||
<ChakraSteps.Item {...rest} ref={ref}>
|
||||
<ChakraSteps.Trigger disabled={disableTrigger}>
|
||||
<ChakraSteps.Indicator>
|
||||
<ChakraSteps.Status complete={completedIcon || <LuCheck />} incomplete={icon || <ChakraSteps.Number />} />
|
||||
</ChakraSteps.Indicator>
|
||||
<StepInfo title={title} description={description} />
|
||||
</ChakraSteps.Trigger>
|
||||
<ChakraSteps.Separator />
|
||||
</ChakraSteps.Item>
|
||||
);
|
||||
});
|
||||
|
||||
const StepInfo = (props: StepInfoProps) => {
|
||||
const { title, description } = props;
|
||||
|
||||
if (title && description) {
|
||||
return (
|
||||
<Box>
|
||||
<ChakraSteps.Title>{title}</ChakraSteps.Title>
|
||||
<ChakraSteps.Description>{description}</ChakraSteps.Description>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && <ChakraSteps.Title>{title}</ChakraSteps.Title>}
|
||||
{description && <ChakraSteps.Description>{description}</ChakraSteps.Description>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface StepsIndicatorProps {
|
||||
completedIcon: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StepsIndicator = React.forwardRef<HTMLDivElement, StepsIndicatorProps>(
|
||||
function StepsIndicator(props, ref) {
|
||||
const { icon = <ChakraSteps.Number />, completedIcon } = props;
|
||||
return (
|
||||
<ChakraSteps.Indicator ref={ref}>
|
||||
<ChakraSteps.Status complete={completedIcon} incomplete={icon} />
|
||||
</ChakraSteps.Indicator>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const StepsList = ChakraSteps.List;
|
||||
export const StepsRoot = ChakraSteps.Root;
|
||||
export const StepsContent = ChakraSteps.Content;
|
||||
export const StepsCompletedContent = ChakraSteps.CompletedContent;
|
||||
|
||||
export const StepsNextTrigger = ChakraSteps.NextTrigger;
|
||||
export const StepsPrevTrigger = ChakraSteps.PrevTrigger;
|
||||
27
src/components/ui/feedback/alert.tsx
Normal file
27
src/components/ui/feedback/alert.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Alert as ChakraAlert } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface AlertProps extends Omit<ChakraAlert.RootProps, 'title'> {
|
||||
startElement?: React.ReactNode;
|
||||
endElement?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
icon?: React.ReactElement;
|
||||
}
|
||||
|
||||
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(props, ref) {
|
||||
const { title, children, icon, startElement, endElement, ...rest } = props;
|
||||
return (
|
||||
<ChakraAlert.Root ref={ref} {...rest}>
|
||||
{startElement || <ChakraAlert.Indicator>{icon}</ChakraAlert.Indicator>}
|
||||
{children ? (
|
||||
<ChakraAlert.Content>
|
||||
<ChakraAlert.Title>{title}</ChakraAlert.Title>
|
||||
<ChakraAlert.Description>{children}</ChakraAlert.Description>
|
||||
</ChakraAlert.Content>
|
||||
) : (
|
||||
<ChakraAlert.Title flex='1'>{title}</ChakraAlert.Title>
|
||||
)}
|
||||
{endElement}
|
||||
</ChakraAlert.Root>
|
||||
);
|
||||
});
|
||||
28
src/components/ui/feedback/empty-state.tsx
Normal file
28
src/components/ui/feedback/empty-state.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { EmptyState as ChakraEmptyState, VStack } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface EmptyStateProps extends ChakraEmptyState.RootProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(props, ref) {
|
||||
const { title, description, icon, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraEmptyState.Root ref={ref} {...rest}>
|
||||
<ChakraEmptyState.Content>
|
||||
{icon && <ChakraEmptyState.Indicator>{icon}</ChakraEmptyState.Indicator>}
|
||||
{description ? (
|
||||
<VStack textAlign='center'>
|
||||
<ChakraEmptyState.Title>{title}</ChakraEmptyState.Title>
|
||||
<ChakraEmptyState.Description>{description}</ChakraEmptyState.Description>
|
||||
</VStack>
|
||||
) : (
|
||||
<ChakraEmptyState.Title>{title}</ChakraEmptyState.Title>
|
||||
)}
|
||||
{children}
|
||||
</ChakraEmptyState.Content>
|
||||
</ChakraEmptyState.Root>
|
||||
);
|
||||
});
|
||||
32
src/components/ui/feedback/progress-circle.tsx
Normal file
32
src/components/ui/feedback/progress-circle.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { SystemStyleObject } from '@chakra-ui/react';
|
||||
import { AbsoluteCenter, ProgressCircle as ChakraProgressCircle } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ProgressCircleRingProps extends ChakraProgressCircle.CircleProps {
|
||||
trackColor?: SystemStyleObject['stroke'];
|
||||
cap?: SystemStyleObject['strokeLinecap'];
|
||||
}
|
||||
|
||||
export const ProgressCircleRing = React.forwardRef<SVGSVGElement, ProgressCircleRingProps>(
|
||||
function ProgressCircleRing(props, ref) {
|
||||
const { trackColor, cap, color, ...rest } = props;
|
||||
return (
|
||||
<ChakraProgressCircle.Circle {...rest} ref={ref}>
|
||||
<ChakraProgressCircle.Track stroke={trackColor} />
|
||||
<ChakraProgressCircle.Range stroke={color} strokeLinecap={cap} />
|
||||
</ChakraProgressCircle.Circle>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ProgressCircleValueText = React.forwardRef<HTMLDivElement, ChakraProgressCircle.ValueTextProps>(
|
||||
function ProgressCircleValueText(props, ref) {
|
||||
return (
|
||||
<AbsoluteCenter>
|
||||
<ChakraProgressCircle.ValueText {...props} ref={ref} />
|
||||
</AbsoluteCenter>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ProgressCircleRoot = ChakraProgressCircle.Root;
|
||||
30
src/components/ui/feedback/progress.tsx
Normal file
30
src/components/ui/feedback/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Progress as ChakraProgress } from '@chakra-ui/react';
|
||||
import { InfoTip } from '../overlays/toggle-tip';
|
||||
import * as React from 'react';
|
||||
|
||||
export const ProgressBar = React.forwardRef<HTMLDivElement, ChakraProgress.TrackProps>(
|
||||
function ProgressBar(props, ref) {
|
||||
return (
|
||||
<ChakraProgress.Track {...props} ref={ref}>
|
||||
<ChakraProgress.Range />
|
||||
</ChakraProgress.Track>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface ProgressLabelProps extends ChakraProgress.LabelProps {
|
||||
info?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProgressLabel = React.forwardRef<HTMLDivElement, ProgressLabelProps>(function ProgressLabel(props, ref) {
|
||||
const { children, info, ...rest } = props;
|
||||
return (
|
||||
<ChakraProgress.Label {...rest} ref={ref}>
|
||||
{children}
|
||||
{info && <InfoTip>{info}</InfoTip>}
|
||||
</ChakraProgress.Label>
|
||||
);
|
||||
});
|
||||
|
||||
export const ProgressRoot = ChakraProgress.Root;
|
||||
export const ProgressValueText = ChakraProgress.ValueText;
|
||||
35
src/components/ui/feedback/skeleton.tsx
Normal file
35
src/components/ui/feedback/skeleton.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { SkeletonProps as ChakraSkeletonProps, CircleProps } from '@chakra-ui/react';
|
||||
import { Skeleton as ChakraSkeleton, Circle, Stack } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface SkeletonCircleProps extends ChakraSkeletonProps {
|
||||
size?: CircleProps['size'];
|
||||
}
|
||||
|
||||
export const SkeletonCircle = React.forwardRef<HTMLDivElement, SkeletonCircleProps>(
|
||||
function SkeletonCircle(props, ref) {
|
||||
const { size, ...rest } = props;
|
||||
return (
|
||||
<Circle size={size} asChild ref={ref}>
|
||||
<ChakraSkeleton {...rest} />
|
||||
</Circle>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface SkeletonTextProps extends ChakraSkeletonProps {
|
||||
noOfLines?: number;
|
||||
}
|
||||
|
||||
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(function SkeletonText(props, ref) {
|
||||
const { noOfLines = 3, gap, ...rest } = props;
|
||||
return (
|
||||
<Stack gap={gap} width='full' ref={ref}>
|
||||
{Array.from({ length: noOfLines }).map((_, index) => (
|
||||
<ChakraSkeleton height='4' key={index} {...props} _last={{ maxW: '80%' }} {...rest} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export const Skeleton = ChakraSkeleton;
|
||||
27
src/components/ui/feedback/status.tsx
Normal file
27
src/components/ui/feedback/status.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ColorPalette } from '@chakra-ui/react';
|
||||
import { Status as ChakraStatus } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
type StatusValue = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface StatusProps extends ChakraStatus.RootProps {
|
||||
value?: StatusValue;
|
||||
}
|
||||
|
||||
const statusMap: Record<StatusValue, ColorPalette> = {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
warning: 'orange',
|
||||
info: 'blue',
|
||||
};
|
||||
|
||||
export const Status = React.forwardRef<HTMLDivElement, StatusProps>(function Status(props, ref) {
|
||||
const { children, value = 'info', ...rest } = props;
|
||||
const colorPalette = rest.colorPalette ?? statusMap[value];
|
||||
return (
|
||||
<ChakraStatus.Root ref={ref} {...rest} colorPalette={colorPalette}>
|
||||
<ChakraStatus.Indicator />
|
||||
{children}
|
||||
</ChakraStatus.Root>
|
||||
);
|
||||
});
|
||||
28
src/components/ui/feedback/toaster.tsx
Normal file
28
src/components/ui/feedback/toaster.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { Toaster as ChakraToaster, Portal, Spinner, Stack, Toast, createToaster } from '@chakra-ui/react';
|
||||
|
||||
export const toaster = createToaster({
|
||||
placement: 'bottom-end',
|
||||
pauseOnPageIdle: true,
|
||||
});
|
||||
|
||||
export const Toaster = () => {
|
||||
return (
|
||||
<Portal>
|
||||
<ChakraToaster toaster={toaster} insetInline={{ mdDown: '4' }}>
|
||||
{(toast) => (
|
||||
<Toast.Root width={{ md: 'sm' }}>
|
||||
{toast.type === 'loading' ? <Spinner size='sm' color='blue.solid' /> : <Toast.Indicator />}
|
||||
<Stack gap='1' flex='1' maxWidth='100%'>
|
||||
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
|
||||
{toast.description && <Toast.Description>{toast.description}</Toast.Description>}
|
||||
</Stack>
|
||||
{toast.action && <Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>}
|
||||
{toast.closable && <Toast.CloseTrigger />}
|
||||
</Toast.Root>
|
||||
)}
|
||||
</ChakraToaster>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
49
src/components/ui/forms/checkbox-card.tsx
Normal file
49
src/components/ui/forms/checkbox-card.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CheckboxCard as ChakraCheckboxCard } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface CheckboxCardProps extends ChakraCheckboxCard.RootProps {
|
||||
icon?: React.ReactElement;
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
addon?: React.ReactNode;
|
||||
indicator?: React.ReactNode | null;
|
||||
indicatorPlacement?: 'start' | 'end' | 'inside';
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const CheckboxCard = React.forwardRef<HTMLInputElement, CheckboxCardProps>(function CheckboxCard(props, ref) {
|
||||
const {
|
||||
inputProps,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
addon,
|
||||
indicator = <ChakraCheckboxCard.Indicator />,
|
||||
indicatorPlacement = 'end',
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const hasContent = label || description || icon;
|
||||
const ContentWrapper = indicator ? ChakraCheckboxCard.Content : React.Fragment;
|
||||
|
||||
return (
|
||||
<ChakraCheckboxCard.Root {...rest}>
|
||||
<ChakraCheckboxCard.HiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraCheckboxCard.Control>
|
||||
{indicatorPlacement === 'start' && indicator}
|
||||
{hasContent && (
|
||||
<ContentWrapper>
|
||||
{icon}
|
||||
{label && <ChakraCheckboxCard.Label>{label}</ChakraCheckboxCard.Label>}
|
||||
{description && <ChakraCheckboxCard.Description>{description}</ChakraCheckboxCard.Description>}
|
||||
{indicatorPlacement === 'inside' && indicator}
|
||||
</ContentWrapper>
|
||||
)}
|
||||
{indicatorPlacement === 'end' && indicator}
|
||||
</ChakraCheckboxCard.Control>
|
||||
{addon && <ChakraCheckboxCard.Addon>{addon}</ChakraCheckboxCard.Addon>}
|
||||
</ChakraCheckboxCard.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export const CheckboxCardIndicator = ChakraCheckboxCard.Indicator;
|
||||
19
src/components/ui/forms/checkbox.tsx
Normal file
19
src/components/ui/forms/checkbox.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Checkbox as ChakraCheckbox } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface CheckboxProps extends ChakraCheckbox.RootProps {
|
||||
icon?: React.ReactNode;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
rootRef?: React.RefObject<HTMLLabelElement | null>;
|
||||
}
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(props, ref) {
|
||||
const { icon, children, inputProps, rootRef, ...rest } = props;
|
||||
return (
|
||||
<ChakraCheckbox.Root ref={rootRef} {...rest}>
|
||||
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraCheckbox.Control>{icon || <ChakraCheckbox.Indicator />}</ChakraCheckbox.Control>
|
||||
{children != null && <ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>}
|
||||
</ChakraCheckbox.Root>
|
||||
);
|
||||
});
|
||||
174
src/components/ui/forms/color-picker.tsx
Normal file
174
src/components/ui/forms/color-picker.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { IconButtonProps, StackProps } from '@chakra-ui/react';
|
||||
import { ColorPicker as ChakraColorPicker, For, IconButton, Portal, Span, Stack, Text, VStack } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuCheck, LuPipette } from 'react-icons/lu';
|
||||
|
||||
export const ColorPickerTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraColorPicker.TriggerProps & { fitContent?: boolean }
|
||||
>(function ColorPickerTrigger(props, ref) {
|
||||
const { fitContent, ...rest } = props;
|
||||
return (
|
||||
<ChakraColorPicker.Trigger data-fit-content={fitContent || undefined} ref={ref} {...rest}>
|
||||
{props.children || <ChakraColorPicker.ValueSwatch />}
|
||||
</ChakraColorPicker.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
export const ColorPickerInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<ChakraColorPicker.ChannelInputProps, 'channel'>
|
||||
>(function ColorHexInput(props, ref) {
|
||||
return <ChakraColorPicker.ChannelInput channel='hex' ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
interface ColorPickerContentProps extends ChakraColorPicker.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const ColorPickerContent = React.forwardRef<HTMLDivElement, ColorPickerContentProps>(
|
||||
function ColorPickerContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraColorPicker.Positioner>
|
||||
<ChakraColorPicker.Content ref={ref} {...rest} />
|
||||
</ChakraColorPicker.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ColorPickerInlineContent = React.forwardRef<HTMLDivElement, ChakraColorPicker.ContentProps>(
|
||||
function ColorPickerInlineContent(props, ref) {
|
||||
return <ChakraColorPicker.Content animation='none' shadow='none' padding='0' ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const ColorPickerSliders = React.forwardRef<HTMLDivElement, StackProps>(function ColorPickerSliders(props, ref) {
|
||||
return (
|
||||
<Stack gap='1' flex='1' px='1' ref={ref} {...props}>
|
||||
<ColorPickerChannelSlider channel='hue' />
|
||||
<ColorPickerChannelSlider channel='alpha' />
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export const ColorPickerArea = React.forwardRef<HTMLDivElement, ChakraColorPicker.AreaProps>(
|
||||
function ColorPickerArea(props, ref) {
|
||||
return (
|
||||
<ChakraColorPicker.Area ref={ref} {...props}>
|
||||
<ChakraColorPicker.AreaBackground />
|
||||
<ChakraColorPicker.AreaThumb />
|
||||
</ChakraColorPicker.Area>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ColorPickerEyeDropper = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
function ColorPickerEyeDropper(props, ref) {
|
||||
return (
|
||||
<ChakraColorPicker.EyeDropperTrigger asChild>
|
||||
<IconButton size='xs' variant='outline' ref={ref} {...props}>
|
||||
<LuPipette />
|
||||
</IconButton>
|
||||
</ChakraColorPicker.EyeDropperTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ColorPickerChannelSlider = React.forwardRef<HTMLDivElement, ChakraColorPicker.ChannelSliderProps>(
|
||||
function ColorPickerSlider(props, ref) {
|
||||
return (
|
||||
<ChakraColorPicker.ChannelSlider ref={ref} {...props}>
|
||||
<ChakraColorPicker.TransparencyGrid size='0.6rem' />
|
||||
<ChakraColorPicker.ChannelSliderTrack />
|
||||
<ChakraColorPicker.ChannelSliderThumb />
|
||||
</ChakraColorPicker.ChannelSlider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ColorPickerSwatchTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ChakraColorPicker.SwatchTriggerProps & {
|
||||
swatchSize?: ChakraColorPicker.SwatchTriggerProps['boxSize'];
|
||||
}
|
||||
>(function ColorPickerSwatchTrigger(props, ref) {
|
||||
const { swatchSize, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraColorPicker.SwatchTrigger ref={ref} style={{ ['--color' as string]: props.value }} {...rest}>
|
||||
{children || (
|
||||
<ChakraColorPicker.Swatch boxSize={swatchSize} value={props.value}>
|
||||
<ChakraColorPicker.SwatchIndicator>
|
||||
<LuCheck />
|
||||
</ChakraColorPicker.SwatchIndicator>
|
||||
</ChakraColorPicker.Swatch>
|
||||
)}
|
||||
</ChakraColorPicker.SwatchTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
export const ColorPickerRoot = React.forwardRef<HTMLDivElement, ChakraColorPicker.RootProps>(
|
||||
function ColorPickerRoot(props, ref) {
|
||||
return (
|
||||
<ChakraColorPicker.Root ref={ref} {...props}>
|
||||
{props.children}
|
||||
<ChakraColorPicker.HiddenInput tabIndex={-1} />
|
||||
</ChakraColorPicker.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const formatMap = {
|
||||
rgba: ['red', 'green', 'blue', 'alpha'],
|
||||
hsla: ['hue', 'saturation', 'lightness', 'alpha'],
|
||||
hsba: ['hue', 'saturation', 'brightness', 'alpha'],
|
||||
hexa: ['hex', 'alpha'],
|
||||
} as const;
|
||||
|
||||
export const ColorPickerChannelInputs = React.forwardRef<HTMLDivElement, ChakraColorPicker.ViewProps>(
|
||||
function ColorPickerChannelInputs(props, ref) {
|
||||
const channels = formatMap[props.format];
|
||||
return (
|
||||
<ChakraColorPicker.View flexDirection='row' ref={ref} {...props}>
|
||||
{channels.map((channel) => (
|
||||
<VStack gap='1' key={channel} flex='1'>
|
||||
<ColorPickerChannelInput channel={channel} px='0' height='7' textStyle='xs' textAlign='center' />
|
||||
<Text textStyle='xs' color='fg.muted' fontWeight='medium'>
|
||||
{channel.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</VStack>
|
||||
))}
|
||||
</ChakraColorPicker.View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ColorPickerChannelSliders = React.forwardRef<HTMLDivElement, ChakraColorPicker.ViewProps>(
|
||||
function ColorPickerChannelSliders(props, ref) {
|
||||
const channels = formatMap[props.format];
|
||||
return (
|
||||
<ChakraColorPicker.View {...props} ref={ref}>
|
||||
<For each={channels}>
|
||||
{(channel) => (
|
||||
<Stack gap='1' key={channel}>
|
||||
<Span textStyle='xs' minW='5ch' textTransform='capitalize' fontWeight='medium'>
|
||||
{channel}
|
||||
</Span>
|
||||
<ColorPickerChannelSlider channel={channel} />
|
||||
</Stack>
|
||||
)}
|
||||
</For>
|
||||
</ChakraColorPicker.View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ColorPickerLabel = ChakraColorPicker.Label;
|
||||
export const ColorPickerControl = ChakraColorPicker.Control;
|
||||
export const ColorPickerValueText = ChakraColorPicker.ValueText;
|
||||
export const ColorPickerValueSwatch = ChakraColorPicker.ValueSwatch;
|
||||
export const ColorPickerChannelInput = ChakraColorPicker.ChannelInput;
|
||||
export const ColorPickerSwatchGroup = ChakraColorPicker.SwatchGroup;
|
||||
26
src/components/ui/forms/field.tsx
Normal file
26
src/components/ui/forms/field.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Field as ChakraField } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface FieldProps extends Omit<ChakraField.RootProps, 'label'> {
|
||||
label?: React.ReactNode;
|
||||
helperText?: React.ReactNode;
|
||||
errorText?: React.ReactNode;
|
||||
optionalText?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(function Field(props, ref) {
|
||||
const { label, children, helperText, errorText, optionalText, ...rest } = props;
|
||||
return (
|
||||
<ChakraField.Root ref={ref} {...rest}>
|
||||
{label && (
|
||||
<ChakraField.Label>
|
||||
{label}
|
||||
<ChakraField.RequiredIndicator fallback={optionalText} />
|
||||
</ChakraField.Label>
|
||||
)}
|
||||
{children}
|
||||
{helperText && <ChakraField.HelperText>{helperText}</ChakraField.HelperText>}
|
||||
{errorText && <ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>}
|
||||
</ChakraField.Root>
|
||||
);
|
||||
});
|
||||
150
src/components/ui/forms/file-upload.tsx
Normal file
150
src/components/ui/forms/file-upload.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import type { ButtonProps, RecipeProps } from '@chakra-ui/react';
|
||||
import {
|
||||
Button,
|
||||
FileUpload as ChakraFileUpload,
|
||||
Icon,
|
||||
IconButton,
|
||||
Span,
|
||||
Text,
|
||||
useFileUploadContext,
|
||||
useRecipe,
|
||||
} from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuFile, LuUpload, LuX } from 'react-icons/lu';
|
||||
|
||||
export interface FileUploadRootProps extends ChakraFileUpload.RootProps {
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const FileUploadRoot = React.forwardRef<HTMLInputElement, FileUploadRootProps>(
|
||||
function FileUploadRoot(props, ref) {
|
||||
const { children, inputProps, ...rest } = props;
|
||||
return (
|
||||
<ChakraFileUpload.Root {...rest}>
|
||||
<ChakraFileUpload.HiddenInput ref={ref} {...inputProps} />
|
||||
{children}
|
||||
</ChakraFileUpload.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface FileUploadDropzoneProps extends ChakraFileUpload.DropzoneProps {
|
||||
label: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FileUploadDropzone = React.forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
|
||||
function FileUploadDropzone(props, ref) {
|
||||
const { children, label, description, ...rest } = props;
|
||||
return (
|
||||
<ChakraFileUpload.Dropzone ref={ref} {...rest}>
|
||||
<Icon fontSize='xl' color='fg.muted'>
|
||||
<LuUpload />
|
||||
</Icon>
|
||||
<ChakraFileUpload.DropzoneContent>
|
||||
<div>{label}</div>
|
||||
{description && <Text color='fg.muted'>{description}</Text>}
|
||||
</ChakraFileUpload.DropzoneContent>
|
||||
{children}
|
||||
</ChakraFileUpload.Dropzone>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface VisibilityProps {
|
||||
showSize?: boolean;
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
interface FileUploadItemProps extends VisibilityProps {
|
||||
file: File;
|
||||
}
|
||||
|
||||
const FileUploadItem = React.forwardRef<HTMLLIElement, FileUploadItemProps>(function FileUploadItem(props, ref) {
|
||||
const { file, showSize, clearable } = props;
|
||||
return (
|
||||
<ChakraFileUpload.Item file={file} ref={ref}>
|
||||
<ChakraFileUpload.ItemPreview asChild>
|
||||
<Icon fontSize='lg' color='fg.muted'>
|
||||
<LuFile />
|
||||
</Icon>
|
||||
</ChakraFileUpload.ItemPreview>
|
||||
|
||||
{showSize ? (
|
||||
<ChakraFileUpload.ItemContent>
|
||||
<ChakraFileUpload.ItemName />
|
||||
<ChakraFileUpload.ItemSizeText />
|
||||
</ChakraFileUpload.ItemContent>
|
||||
) : (
|
||||
<ChakraFileUpload.ItemName flex='1' />
|
||||
)}
|
||||
|
||||
{clearable && (
|
||||
<ChakraFileUpload.ItemDeleteTrigger asChild>
|
||||
<IconButton variant='ghost' color='fg.muted' size='xs'>
|
||||
<LuX />
|
||||
</IconButton>
|
||||
</ChakraFileUpload.ItemDeleteTrigger>
|
||||
)}
|
||||
</ChakraFileUpload.Item>
|
||||
);
|
||||
});
|
||||
|
||||
interface FileUploadListProps extends VisibilityProps, ChakraFileUpload.ItemGroupProps {
|
||||
files?: File[];
|
||||
}
|
||||
|
||||
export const FileUploadList = React.forwardRef<HTMLUListElement, FileUploadListProps>(
|
||||
function FileUploadList(props, ref) {
|
||||
const { showSize, clearable, files, ...rest } = props;
|
||||
|
||||
const fileUpload = useFileUploadContext();
|
||||
const acceptedFiles = files ?? fileUpload.acceptedFiles;
|
||||
|
||||
if (acceptedFiles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ChakraFileUpload.ItemGroup ref={ref} {...rest}>
|
||||
{acceptedFiles.map((file) => (
|
||||
<FileUploadItem key={file.name} file={file} showSize={showSize} clearable={clearable} />
|
||||
))}
|
||||
</ChakraFileUpload.ItemGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type Assign<T, U> = Omit<T, keyof U> & U;
|
||||
|
||||
interface FileInputProps extends Assign<ButtonProps, RecipeProps<'input'>> {
|
||||
placeholder?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FileInput = React.forwardRef<HTMLButtonElement, FileInputProps>(function FileInput(props, ref) {
|
||||
const inputRecipe = useRecipe({ key: 'input' });
|
||||
const [recipeProps, restProps] = inputRecipe.splitVariantProps(props);
|
||||
const { placeholder = 'Select file(s)', ...rest } = restProps;
|
||||
return (
|
||||
<ChakraFileUpload.Trigger asChild>
|
||||
<Button unstyled py='0' ref={ref} {...rest} css={[inputRecipe(recipeProps), props.css]}>
|
||||
<ChakraFileUpload.Context>
|
||||
{({ acceptedFiles }) => {
|
||||
if (acceptedFiles.length === 1) {
|
||||
return <span>{acceptedFiles[0].name}</span>;
|
||||
}
|
||||
if (acceptedFiles.length > 1) {
|
||||
return <span>{acceptedFiles.length} files</span>;
|
||||
}
|
||||
return <Span color='fg.subtle'>{placeholder}</Span>;
|
||||
}}
|
||||
</ChakraFileUpload.Context>
|
||||
</Button>
|
||||
</ChakraFileUpload.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
export const FileUploadLabel = ChakraFileUpload.Label;
|
||||
export const FileUploadClearTrigger = ChakraFileUpload.ClearTrigger;
|
||||
export const FileUploadTrigger = ChakraFileUpload.Trigger;
|
||||
export const FileUploadFileText = ChakraFileUpload.FileText;
|
||||
50
src/components/ui/forms/input-group.tsx
Normal file
50
src/components/ui/forms/input-group.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { BoxProps, InputElementProps } from '@chakra-ui/react';
|
||||
import { Group, InputElement } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface InputGroupProps extends BoxProps {
|
||||
startElementProps?: InputElementProps;
|
||||
endElementProps?: InputElementProps;
|
||||
startElement?: React.ReactNode;
|
||||
endElement?: React.ReactNode;
|
||||
children: React.ReactElement<InputElementProps>;
|
||||
startOffset?: InputElementProps['paddingStart'];
|
||||
endOffset?: InputElementProps['paddingEnd'];
|
||||
}
|
||||
|
||||
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(function InputGroup(props, ref) {
|
||||
const {
|
||||
startElement,
|
||||
startElementProps,
|
||||
endElement,
|
||||
endElementProps,
|
||||
children,
|
||||
startOffset = '6px',
|
||||
endOffset = '6px',
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const child = React.Children.only<React.ReactElement<InputElementProps>>(children);
|
||||
|
||||
return (
|
||||
<Group ref={ref} {...rest}>
|
||||
{startElement && (
|
||||
<InputElement pointerEvents='none' {...startElementProps}>
|
||||
{startElement}
|
||||
</InputElement>
|
||||
)}
|
||||
{React.cloneElement(child, {
|
||||
...(startElement && {
|
||||
ps: `calc(var(--input-height) - ${startOffset})`,
|
||||
}),
|
||||
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
|
||||
...children.props,
|
||||
})}
|
||||
{endElement && (
|
||||
<InputElement placement='end' {...endElementProps}>
|
||||
{endElement}
|
||||
</InputElement>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
52
src/components/ui/forms/native-select.tsx
Normal file
52
src/components/ui/forms/native-select.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { NativeSelect as Select } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface NativeSelectRootProps extends Select.RootProps {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const NativeSelectRoot = React.forwardRef<HTMLDivElement, NativeSelectRootProps>(
|
||||
function NativeSelect(props, ref) {
|
||||
const { icon, children, ...rest } = props;
|
||||
return (
|
||||
<Select.Root ref={ref} {...rest}>
|
||||
{children}
|
||||
<Select.Indicator>{icon}</Select.Indicator>
|
||||
</Select.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface NativeSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface NativeSelectFieldProps extends Select.FieldProps {
|
||||
items?: Array<string | NativeSelectItem>;
|
||||
}
|
||||
|
||||
export const NativeSelectField = React.forwardRef<HTMLSelectElement, NativeSelectFieldProps>(
|
||||
function NativeSelectField(props, ref) {
|
||||
const { items: itemsProp, children, ...rest } = props;
|
||||
|
||||
const items = React.useMemo(
|
||||
() => itemsProp?.map((item) => (typeof item === 'string' ? { label: item, value: item } : item)),
|
||||
[itemsProp],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select.Field ref={ref} {...rest}>
|
||||
{children}
|
||||
{items?.map((item) => (
|
||||
<option key={item.value} value={item.value} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</Select.Field>
|
||||
);
|
||||
},
|
||||
);
|
||||
21
src/components/ui/forms/number-input.tsx
Normal file
21
src/components/ui/forms/number-input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NumberInput as ChakraNumberInput } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface NumberInputProps extends ChakraNumberInput.RootProps {}
|
||||
|
||||
export const NumberInputRoot = React.forwardRef<HTMLDivElement, NumberInputProps>(function NumberInput(props, ref) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ChakraNumberInput.Root ref={ref} variant='outline' {...rest}>
|
||||
{children}
|
||||
<ChakraNumberInput.Control>
|
||||
<ChakraNumberInput.IncrementTrigger />
|
||||
<ChakraNumberInput.DecrementTrigger />
|
||||
</ChakraNumberInput.Control>
|
||||
</ChakraNumberInput.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export const NumberInputField = ChakraNumberInput.Input;
|
||||
export const NumberInputScrubber = ChakraNumberInput.Scrubber;
|
||||
export const NumberInputLabel = ChakraNumberInput.Label;
|
||||
136
src/components/ui/forms/password-input.tsx
Normal file
136
src/components/ui/forms/password-input.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import type { ButtonProps, GroupProps, InputProps, StackProps } from '@chakra-ui/react';
|
||||
import { Box, HStack, IconButton, Input, InputGroup, Stack, mergeRefs, useControllableState } from '@chakra-ui/react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from 'react';
|
||||
import { LuEye, LuEyeOff } from 'react-icons/lu';
|
||||
|
||||
export interface PasswordVisibilityProps {
|
||||
/**
|
||||
* The default visibility state of the password input.
|
||||
*/
|
||||
defaultVisible?: boolean;
|
||||
/**
|
||||
* The controlled visibility state of the password input.
|
||||
*/
|
||||
visible?: boolean;
|
||||
/**
|
||||
* Callback invoked when the visibility state changes.
|
||||
*/
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
/**
|
||||
* Custom icons for the visibility toggle button.
|
||||
*/
|
||||
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode };
|
||||
}
|
||||
|
||||
export interface PasswordInputProps extends InputProps, PasswordVisibilityProps {
|
||||
rootProps?: GroupProps;
|
||||
}
|
||||
|
||||
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(function PasswordInput(props, ref) {
|
||||
const {
|
||||
rootProps,
|
||||
defaultVisible,
|
||||
visible: visibleProp,
|
||||
onVisibleChange,
|
||||
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [visible, setVisible] = useControllableState({
|
||||
value: visibleProp,
|
||||
defaultValue: defaultVisible || false,
|
||||
onChange: onVisibleChange,
|
||||
});
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<InputGroup
|
||||
endElement={
|
||||
<VisibilityTrigger
|
||||
disabled={rest.disabled}
|
||||
onPointerDown={(e) => {
|
||||
if (rest.disabled) return;
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setVisible(!visible);
|
||||
}}
|
||||
>
|
||||
{visible ? visibilityIcon.off : visibilityIcon.on}
|
||||
</VisibilityTrigger>
|
||||
}
|
||||
{...rootProps}
|
||||
>
|
||||
<Input {...rest} ref={mergeRefs(ref, inputRef)} type={visible ? 'text' : 'password'} />
|
||||
</InputGroup>
|
||||
);
|
||||
});
|
||||
|
||||
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(function VisibilityTrigger(props, ref) {
|
||||
return (
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
me='-2'
|
||||
aspectRatio='square'
|
||||
borderRadius='full'
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
height='calc(100% - {spacing.2})'
|
||||
aria-label='Toggle password visibility'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface PasswordStrengthMeterProps extends StackProps {
|
||||
max?: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter = React.forwardRef<HTMLDivElement, PasswordStrengthMeterProps>(
|
||||
function PasswordStrengthMeter(props, ref) {
|
||||
const { max = 4, value, ...rest } = props;
|
||||
const t = useTranslations();
|
||||
|
||||
function getColorPalette(percent: number) {
|
||||
switch (true) {
|
||||
case percent < 33:
|
||||
return { label: t('low'), colorPalette: 'red' };
|
||||
case percent < 66:
|
||||
return { label: t('medium'), colorPalette: 'orange' };
|
||||
default:
|
||||
return { label: t('high'), colorPalette: 'green' };
|
||||
}
|
||||
}
|
||||
|
||||
const percent = (value / max) * 100;
|
||||
const { label, colorPalette } = getColorPalette(percent);
|
||||
|
||||
return (
|
||||
<Stack align='flex-end' gap='1' ref={ref} {...rest}>
|
||||
<HStack width='full' {...rest}>
|
||||
{Array.from({ length: max }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
height='1'
|
||||
flex='1'
|
||||
rounded='sm'
|
||||
data-selected={index < value ? '' : undefined}
|
||||
layerStyle='fill.subtle'
|
||||
colorPalette='gray'
|
||||
_selected={{
|
||||
colorPalette,
|
||||
layerStyle: 'fill.solid',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{label && <HStack textStyle='xs'>{label}</HStack>}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
25
src/components/ui/forms/pin-input.tsx
Normal file
25
src/components/ui/forms/pin-input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PinInput as ChakraPinInput, Group } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface PinInputProps extends ChakraPinInput.RootProps {
|
||||
rootRef?: React.RefObject<HTMLDivElement | null>;
|
||||
count?: number;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
attached?: boolean;
|
||||
}
|
||||
|
||||
export const PinInput = React.forwardRef<HTMLInputElement, PinInputProps>(function PinInput(props, ref) {
|
||||
const { count = 4, inputProps, rootRef, attached, ...rest } = props;
|
||||
return (
|
||||
<ChakraPinInput.Root ref={rootRef} {...rest}>
|
||||
<ChakraPinInput.HiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraPinInput.Control>
|
||||
<Group attached={attached}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<ChakraPinInput.Input key={index} index={index} />
|
||||
))}
|
||||
</Group>
|
||||
</ChakraPinInput.Control>
|
||||
</ChakraPinInput.Root>
|
||||
);
|
||||
});
|
||||
51
src/components/ui/forms/radio-card.tsx
Normal file
51
src/components/ui/forms/radio-card.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { RadioCard } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface RadioCardItemProps extends RadioCard.ItemProps {
|
||||
icon?: React.ReactElement;
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
addon?: React.ReactNode;
|
||||
indicator?: React.ReactNode | null;
|
||||
indicatorPlacement?: 'start' | 'end' | 'inside';
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const RadioCardItem = React.forwardRef<HTMLInputElement, RadioCardItemProps>(function RadioCardItem(props, ref) {
|
||||
const {
|
||||
inputProps,
|
||||
label,
|
||||
description,
|
||||
addon,
|
||||
icon,
|
||||
indicator = <RadioCard.ItemIndicator />,
|
||||
indicatorPlacement = 'end',
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const hasContent = label || description || icon;
|
||||
const ContentWrapper = indicator ? RadioCard.ItemContent : React.Fragment;
|
||||
|
||||
return (
|
||||
<RadioCard.Item {...rest}>
|
||||
<RadioCard.ItemHiddenInput ref={ref} {...inputProps} />
|
||||
<RadioCard.ItemControl>
|
||||
{indicatorPlacement === 'start' && indicator}
|
||||
{hasContent && (
|
||||
<ContentWrapper>
|
||||
{icon}
|
||||
{label && <RadioCard.ItemText>{label}</RadioCard.ItemText>}
|
||||
{description && <RadioCard.ItemDescription>{description}</RadioCard.ItemDescription>}
|
||||
{indicatorPlacement === 'inside' && indicator}
|
||||
</ContentWrapper>
|
||||
)}
|
||||
{indicatorPlacement === 'end' && indicator}
|
||||
</RadioCard.ItemControl>
|
||||
{addon && <RadioCard.ItemAddon>{addon}</RadioCard.ItemAddon>}
|
||||
</RadioCard.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export const RadioCardRoot = RadioCard.Root;
|
||||
export const RadioCardLabel = RadioCard.Label;
|
||||
export const RadioCardItemIndicator = RadioCard.ItemIndicator;
|
||||
20
src/components/ui/forms/radio.tsx
Normal file
20
src/components/ui/forms/radio.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { RadioGroup as ChakraRadioGroup } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface RadioProps extends ChakraRadioGroup.ItemProps {
|
||||
rootRef?: React.RefObject<HTMLDivElement | null>;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(function Radio(props, ref) {
|
||||
const { children, inputProps, rootRef, ...rest } = props;
|
||||
return (
|
||||
<ChakraRadioGroup.Item ref={rootRef} {...rest}>
|
||||
<ChakraRadioGroup.ItemHiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraRadioGroup.ItemIndicator />
|
||||
{children && <ChakraRadioGroup.ItemText>{children}</ChakraRadioGroup.ItemText>}
|
||||
</ChakraRadioGroup.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export const RadioGroup = ChakraRadioGroup.Root;
|
||||
25
src/components/ui/forms/rating.tsx
Normal file
25
src/components/ui/forms/rating.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { RatingGroup } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface RatingProps extends RatingGroup.RootProps {
|
||||
icon?: React.ReactElement;
|
||||
count?: number;
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Rating = React.forwardRef<HTMLDivElement, RatingProps>(function Rating(props, ref) {
|
||||
const { icon, count = 5, label, ...rest } = props;
|
||||
return (
|
||||
<RatingGroup.Root ref={ref} count={count} {...rest}>
|
||||
{label && <RatingGroup.Label>{label}</RatingGroup.Label>}
|
||||
<RatingGroup.HiddenInput />
|
||||
<RatingGroup.Control>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<RatingGroup.Item key={index} index={index + 1}>
|
||||
<RatingGroup.ItemIndicator icon={icon} />
|
||||
</RatingGroup.Item>
|
||||
))}
|
||||
</RatingGroup.Control>
|
||||
</RatingGroup.Root>
|
||||
);
|
||||
});
|
||||
42
src/components/ui/forms/segmented-control.tsx
Normal file
42
src/components/ui/forms/segmented-control.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { For, SegmentGroup } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface Item {
|
||||
value: string;
|
||||
label: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps extends SegmentGroup.RootProps {
|
||||
items: Array<string | Item>;
|
||||
}
|
||||
|
||||
function normalize(items: Array<string | Item>): Item[] {
|
||||
return items.map((item) => {
|
||||
if (typeof item === 'string') return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export const SegmentedControl = React.forwardRef<HTMLDivElement, SegmentedControlProps>(
|
||||
function SegmentedControl(props, ref) {
|
||||
const { items, ...rest } = props;
|
||||
const data = React.useMemo(() => normalize(items), [items]);
|
||||
|
||||
return (
|
||||
<SegmentGroup.Root ref={ref} {...rest}>
|
||||
<SegmentGroup.Indicator />
|
||||
<For each={data}>
|
||||
{(item) => (
|
||||
<SegmentGroup.Item key={item.value} value={item.value} disabled={item.disabled}>
|
||||
<SegmentGroup.ItemText>{item.label}</SegmentGroup.ItemText>
|
||||
<SegmentGroup.ItemHiddenInput />
|
||||
</SegmentGroup.Item>
|
||||
)}
|
||||
</For>
|
||||
</SegmentGroup.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
78
src/components/ui/forms/slider.tsx
Normal file
78
src/components/ui/forms/slider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Slider as ChakraSlider, For, HStack } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface SliderProps extends ChakraSlider.RootProps {
|
||||
marks?: Array<number | { value: number; label: React.ReactNode }>;
|
||||
label?: React.ReactNode;
|
||||
showValue?: boolean;
|
||||
thumb?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(function Slider(props, ref) {
|
||||
const { marks: marksProp, label, showValue, thumb, ...rest } = props;
|
||||
const value = props.defaultValue ?? props.value;
|
||||
|
||||
const marks = marksProp?.map((mark) => {
|
||||
if (typeof mark === 'number') return { value: mark, label: undefined };
|
||||
return mark;
|
||||
});
|
||||
|
||||
const hasMarkLabel = !!marks?.some((mark) => mark.label);
|
||||
|
||||
return (
|
||||
<ChakraSlider.Root ref={ref} thumbAlignment='center' {...rest}>
|
||||
{label && !showValue && <ChakraSlider.Label>{label}</ChakraSlider.Label>}
|
||||
{label && showValue && (
|
||||
<HStack justify='space-between'>
|
||||
<ChakraSlider.Label>{label}</ChakraSlider.Label>
|
||||
<ChakraSlider.ValueText />
|
||||
</HStack>
|
||||
)}
|
||||
<ChakraSlider.Control data-has-mark-label={hasMarkLabel || undefined}>
|
||||
<ChakraSlider.Track>
|
||||
<ChakraSlider.Range />
|
||||
</ChakraSlider.Track>
|
||||
<SliderThumbs value={value} thumb={thumb} />
|
||||
<SliderMarks marks={marks} />
|
||||
</ChakraSlider.Control>
|
||||
</ChakraSlider.Root>
|
||||
);
|
||||
});
|
||||
|
||||
function SliderThumbs(props: { value?: number[]; thumb?: React.ReactNode }) {
|
||||
const { value, thumb } = props;
|
||||
return (
|
||||
<For each={value}>
|
||||
{(_, index) => (
|
||||
<ChakraSlider.Thumb key={index} index={index}>
|
||||
<ChakraSlider.HiddenInput />
|
||||
{thumb}
|
||||
</ChakraSlider.Thumb>
|
||||
)}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
|
||||
interface SliderMarksProps {
|
||||
marks?: Array<number | { value: number; label: React.ReactNode }>;
|
||||
}
|
||||
|
||||
const SliderMarks = React.forwardRef<HTMLDivElement, SliderMarksProps>(function SliderMarks(props, ref) {
|
||||
const { marks } = props;
|
||||
if (!marks?.length) return null;
|
||||
|
||||
return (
|
||||
<ChakraSlider.MarkerGroup ref={ref}>
|
||||
{marks.map((mark, index) => {
|
||||
const value = typeof mark === 'number' ? mark : mark.value;
|
||||
const label = typeof mark === 'number' ? undefined : mark.label;
|
||||
return (
|
||||
<ChakraSlider.Marker key={index} value={value}>
|
||||
<ChakraSlider.MarkerIndicator />
|
||||
{label}
|
||||
</ChakraSlider.Marker>
|
||||
);
|
||||
})}
|
||||
</ChakraSlider.MarkerGroup>
|
||||
);
|
||||
});
|
||||
45
src/components/ui/forms/stepper-input.tsx
Normal file
45
src/components/ui/forms/stepper-input.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { HStack, IconButton, NumberInput } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuMinus, LuPlus } from 'react-icons/lu';
|
||||
|
||||
export interface StepperInputProps extends NumberInput.RootProps {
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StepperInput = React.forwardRef<HTMLDivElement, StepperInputProps>(function StepperInput(props, ref) {
|
||||
const { label, ...rest } = props;
|
||||
return (
|
||||
<NumberInput.Root {...rest} unstyled ref={ref}>
|
||||
{label && <NumberInput.Label>{label}</NumberInput.Label>}
|
||||
<HStack gap='2'>
|
||||
<DecrementTrigger />
|
||||
<NumberInput.ValueText textAlign='center' fontSize='lg' minW='3ch' />
|
||||
<IncrementTrigger />
|
||||
</HStack>
|
||||
</NumberInput.Root>
|
||||
);
|
||||
});
|
||||
|
||||
const DecrementTrigger = React.forwardRef<HTMLButtonElement, NumberInput.DecrementTriggerProps>(
|
||||
function DecrementTrigger(props, ref) {
|
||||
return (
|
||||
<NumberInput.DecrementTrigger {...props} asChild ref={ref}>
|
||||
<IconButton variant='outline' size='sm'>
|
||||
<LuMinus />
|
||||
</IconButton>
|
||||
</NumberInput.DecrementTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const IncrementTrigger = React.forwardRef<HTMLButtonElement, NumberInput.IncrementTriggerProps>(
|
||||
function IncrementTrigger(props, ref) {
|
||||
return (
|
||||
<NumberInput.IncrementTrigger {...props} asChild ref={ref}>
|
||||
<IconButton variant='outline' size='sm'>
|
||||
<LuPlus />
|
||||
</IconButton>
|
||||
</NumberInput.IncrementTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
28
src/components/ui/forms/switch.tsx
Normal file
28
src/components/ui/forms/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Switch as ChakraSwitch } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface SwitchProps extends ChakraSwitch.RootProps {
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
rootRef?: React.RefObject<HTMLLabelElement | null>;
|
||||
trackLabel?: { on: React.ReactNode; off: React.ReactNode };
|
||||
thumbLabel?: { on: React.ReactNode; off: React.ReactNode };
|
||||
}
|
||||
|
||||
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(function Switch(props, ref) {
|
||||
const { inputProps, children, rootRef, trackLabel, thumbLabel, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ChakraSwitch.Root ref={rootRef} {...rest}>
|
||||
<ChakraSwitch.HiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraSwitch.Control>
|
||||
<ChakraSwitch.Thumb>
|
||||
{thumbLabel && (
|
||||
<ChakraSwitch.ThumbIndicator fallback={thumbLabel?.off}>{thumbLabel?.on}</ChakraSwitch.ThumbIndicator>
|
||||
)}
|
||||
</ChakraSwitch.Thumb>
|
||||
{trackLabel && <ChakraSwitch.Indicator fallback={trackLabel.off}>{trackLabel.on}</ChakraSwitch.Indicator>}
|
||||
</ChakraSwitch.Control>
|
||||
{children != null && <ChakraSwitch.Label>{children}</ChakraSwitch.Label>}
|
||||
</ChakraSwitch.Root>
|
||||
);
|
||||
});
|
||||
67
src/components/ui/locale-switcher.tsx
Normal file
67
src/components/ui/locale-switcher.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React, { useTransition } from 'react';
|
||||
import { Locale, useLocale } from 'next-intl';
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
} from '@/components/ui/collections/select';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { createListCollection } from '@chakra-ui/react';
|
||||
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
|
||||
const LocaleSwitcher = () => {
|
||||
const locale = useLocale();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const collections = createListCollection({
|
||||
items: [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Türkçe', value: 'tr' },
|
||||
],
|
||||
});
|
||||
|
||||
function onSelectChange({ value }: { value: string[] }) {
|
||||
const nextLocale = value.at(0) as Locale;
|
||||
startTransition(() => {
|
||||
router.replace(
|
||||
// @ts-expect-error -- TypeScript will validate that only known `params`
|
||||
// are used in combination with a given `pathname`. Since the two will
|
||||
// always match for the current route, we can skip runtime checks.
|
||||
{ pathname, params },
|
||||
{ locale: nextLocale },
|
||||
);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<SelectRoot
|
||||
disabled={isPending}
|
||||
value={[locale]}
|
||||
onValueChange={onSelectChange}
|
||||
w={{ base: 'full', lg: '24' }}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
borderRadius='md'
|
||||
collection={collections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder='Select a language' />
|
||||
</SelectTrigger>
|
||||
<SelectContent zIndex='9999'>
|
||||
{collections.items.map((collection) => (
|
||||
<SelectItem key={collection.value} item={collection}>
|
||||
{collection.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocaleSwitcher;
|
||||
38
src/components/ui/overlays/action-bar.tsx
Normal file
38
src/components/ui/overlays/action-bar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ActionBar, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ActionBarContentProps extends ActionBar.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const ActionBarContent = React.forwardRef<HTMLDivElement, ActionBarContentProps>(
|
||||
function ActionBarContent(props, ref) {
|
||||
const { children, portalled = true, portalRef, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ActionBar.Positioner>
|
||||
<ActionBar.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ActionBar.Content>
|
||||
</ActionBar.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ActionBarCloseTrigger = React.forwardRef<HTMLButtonElement, ActionBar.CloseTriggerProps>(
|
||||
function ActionBarCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ActionBar.CloseTrigger {...props} asChild ref={ref}>
|
||||
<CloseButton size='sm' />
|
||||
</ActionBar.CloseTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ActionBarRoot = ActionBar.Root;
|
||||
export const ActionBarSelectionTrigger = ActionBar.SelectionTrigger;
|
||||
export const ActionBarSeparator = ActionBar.Separator;
|
||||
46
src/components/ui/overlays/dialog.tsx
Normal file
46
src/components/ui/overlays/dialog.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Dialog as ChakraDialog, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface DialogContentProps extends ChakraDialog.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
backdrop?: boolean;
|
||||
}
|
||||
|
||||
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(function DialogContent(props, ref) {
|
||||
const { children, portalled = true, portalRef, backdrop = true, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
{backdrop && <ChakraDialog.Backdrop />}
|
||||
<ChakraDialog.Positioner>
|
||||
<ChakraDialog.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ChakraDialog.Content>
|
||||
</ChakraDialog.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const DialogCloseTrigger = React.forwardRef<HTMLButtonElement, ChakraDialog.CloseTriggerProps>(
|
||||
function DialogCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraDialog.CloseTrigger position='absolute' top='2' insetEnd='2' {...props} asChild>
|
||||
<CloseButton size='sm' ref={ref}>
|
||||
{props.children}
|
||||
</CloseButton>
|
||||
</ChakraDialog.CloseTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const DialogRoot = ChakraDialog.Root;
|
||||
export const DialogFooter = ChakraDialog.Footer;
|
||||
export const DialogHeader = ChakraDialog.Header;
|
||||
export const DialogBody = ChakraDialog.Body;
|
||||
export const DialogBackdrop = ChakraDialog.Backdrop;
|
||||
export const DialogTitle = ChakraDialog.Title;
|
||||
export const DialogDescription = ChakraDialog.Description;
|
||||
export const DialogTrigger = ChakraDialog.Trigger;
|
||||
export const DialogActionTrigger = ChakraDialog.ActionTrigger;
|
||||
42
src/components/ui/overlays/drawer.tsx
Normal file
42
src/components/ui/overlays/drawer.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Drawer as ChakraDrawer, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface DrawerContentProps extends ChakraDrawer.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
offset?: ChakraDrawer.ContentProps['padding'];
|
||||
}
|
||||
|
||||
export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(function DrawerContent(props, ref) {
|
||||
const { children, portalled = true, portalRef, offset, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraDrawer.Positioner padding={offset}>
|
||||
<ChakraDrawer.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ChakraDrawer.Content>
|
||||
</ChakraDrawer.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const DrawerCloseTrigger = React.forwardRef<HTMLButtonElement, ChakraDrawer.CloseTriggerProps>(
|
||||
function DrawerCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraDrawer.CloseTrigger position='absolute' top='2' insetEnd='2' {...props} asChild>
|
||||
<CloseButton size='sm' ref={ref} />
|
||||
</ChakraDrawer.CloseTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const DrawerTrigger = ChakraDrawer.Trigger;
|
||||
export const DrawerRoot = ChakraDrawer.Root;
|
||||
export const DrawerFooter = ChakraDrawer.Footer;
|
||||
export const DrawerHeader = ChakraDrawer.Header;
|
||||
export const DrawerBody = ChakraDrawer.Body;
|
||||
export const DrawerBackdrop = ChakraDrawer.Backdrop;
|
||||
export const DrawerDescription = ChakraDrawer.Description;
|
||||
export const DrawerTitle = ChakraDrawer.Title;
|
||||
export const DrawerActionTrigger = ChakraDrawer.ActionTrigger;
|
||||
34
src/components/ui/overlays/hover-card.tsx
Normal file
34
src/components/ui/overlays/hover-card.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { HoverCard, Portal } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface HoverCardContentProps extends HoverCard.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const HoverCardContent = React.forwardRef<HTMLDivElement, HoverCardContentProps>(
|
||||
function HoverCardContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<HoverCard.Positioner>
|
||||
<HoverCard.Content ref={ref} {...rest} />
|
||||
</HoverCard.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const HoverCardArrow = React.forwardRef<HTMLDivElement, HoverCard.ArrowProps>(
|
||||
function HoverCardArrow(props, ref) {
|
||||
return (
|
||||
<HoverCard.Arrow ref={ref} {...props}>
|
||||
<HoverCard.ArrowTip />
|
||||
</HoverCard.Arrow>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const HoverCardRoot = HoverCard.Root;
|
||||
export const HoverCardTrigger = HoverCard.Trigger;
|
||||
99
src/components/ui/overlays/menu.tsx
Normal file
99
src/components/ui/overlays/menu.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuCheck, LuChevronRight } from 'react-icons/lu';
|
||||
|
||||
interface MenuContentProps extends ChakraMenu.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(function MenuContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraMenu.Positioner>
|
||||
<ChakraMenu.Content ref={ref} {...rest} />
|
||||
</ChakraMenu.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const MenuArrow = React.forwardRef<HTMLDivElement, ChakraMenu.ArrowProps>(function MenuArrow(props, ref) {
|
||||
return (
|
||||
<ChakraMenu.Arrow ref={ref} {...props}>
|
||||
<ChakraMenu.ArrowTip />
|
||||
</ChakraMenu.Arrow>
|
||||
);
|
||||
});
|
||||
|
||||
export const MenuCheckboxItem = React.forwardRef<HTMLDivElement, ChakraMenu.CheckboxItemProps>(
|
||||
function MenuCheckboxItem(props, ref) {
|
||||
return (
|
||||
<ChakraMenu.CheckboxItem ps='8' ref={ref} {...props}>
|
||||
<AbsoluteCenter axis='horizontal' insetStart='4' asChild>
|
||||
<ChakraMenu.ItemIndicator>
|
||||
<LuCheck />
|
||||
</ChakraMenu.ItemIndicator>
|
||||
</AbsoluteCenter>
|
||||
{props.children}
|
||||
</ChakraMenu.CheckboxItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MenuRadioItem = React.forwardRef<HTMLDivElement, ChakraMenu.RadioItemProps>(
|
||||
function MenuRadioItem(props, ref) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ChakraMenu.RadioItem ps='8' ref={ref} {...rest}>
|
||||
<AbsoluteCenter axis='horizontal' insetStart='4' asChild>
|
||||
<ChakraMenu.ItemIndicator>
|
||||
<LuCheck />
|
||||
</ChakraMenu.ItemIndicator>
|
||||
</AbsoluteCenter>
|
||||
<ChakraMenu.ItemText>{children}</ChakraMenu.ItemText>
|
||||
</ChakraMenu.RadioItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MenuItemGroup = React.forwardRef<HTMLDivElement, ChakraMenu.ItemGroupProps>(
|
||||
function MenuItemGroup(props, ref) {
|
||||
const { title, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraMenu.ItemGroup ref={ref} {...rest}>
|
||||
{title && <ChakraMenu.ItemGroupLabel userSelect='none'>{title}</ChakraMenu.ItemGroupLabel>}
|
||||
{children}
|
||||
</ChakraMenu.ItemGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
|
||||
startIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MenuTriggerItem = React.forwardRef<HTMLDivElement, MenuTriggerItemProps>(
|
||||
function MenuTriggerItem(props, ref) {
|
||||
const { startIcon, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraMenu.TriggerItem ref={ref} {...rest}>
|
||||
{startIcon}
|
||||
{children}
|
||||
<LuChevronRight />
|
||||
</ChakraMenu.TriggerItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup;
|
||||
export const MenuContextTrigger = ChakraMenu.ContextTrigger;
|
||||
export const MenuRoot = ChakraMenu.Root;
|
||||
export const MenuSeparator = ChakraMenu.Separator;
|
||||
|
||||
export const MenuItem = ChakraMenu.Item;
|
||||
export const MenuItemText = ChakraMenu.ItemText;
|
||||
export const MenuItemCommand = ChakraMenu.ItemCommand;
|
||||
export const MenuTrigger = ChakraMenu.Trigger;
|
||||
49
src/components/ui/overlays/popover.tsx
Normal file
49
src/components/ui/overlays/popover.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Popover as ChakraPopover, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '../buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface PopoverContentProps extends ChakraPopover.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||
function PopoverContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraPopover.Positioner>
|
||||
<ChakraPopover.Content ref={ref} {...rest} />
|
||||
</ChakraPopover.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PopoverArrow = React.forwardRef<HTMLDivElement, ChakraPopover.ArrowProps>(
|
||||
function PopoverArrow(props, ref) {
|
||||
return (
|
||||
<ChakraPopover.Arrow {...props} ref={ref}>
|
||||
<ChakraPopover.ArrowTip />
|
||||
</ChakraPopover.Arrow>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PopoverCloseTrigger = React.forwardRef<HTMLButtonElement, ChakraPopover.CloseTriggerProps>(
|
||||
function PopoverCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraPopover.CloseTrigger position='absolute' top='1' insetEnd='1' {...props} asChild ref={ref}>
|
||||
<CloseButton size='sm' />
|
||||
</ChakraPopover.CloseTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PopoverTitle = ChakraPopover.Title;
|
||||
export const PopoverDescription = ChakraPopover.Description;
|
||||
export const PopoverFooter = ChakraPopover.Footer;
|
||||
export const PopoverHeader = ChakraPopover.Header;
|
||||
export const PopoverRoot = ChakraPopover.Root;
|
||||
export const PopoverBody = ChakraPopover.Body;
|
||||
export const PopoverTrigger = ChakraPopover.Trigger;
|
||||
48
src/components/ui/overlays/toggle-tip.tsx
Normal file
48
src/components/ui/overlays/toggle-tip.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Popover as ChakraPopover, IconButton, type IconButtonProps, Portal } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { HiOutlineInformationCircle } from 'react-icons/hi';
|
||||
|
||||
export interface ToggleTipProps extends ChakraPopover.RootProps {
|
||||
showArrow?: boolean;
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
content?: React.ReactNode;
|
||||
contentProps?: ChakraPopover.ContentProps;
|
||||
}
|
||||
|
||||
export const ToggleTip = React.forwardRef<HTMLDivElement, ToggleTipProps>(function ToggleTip(props, ref) {
|
||||
const { showArrow, children, portalled = true, content, contentProps, portalRef, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ChakraPopover.Root {...rest} positioning={{ ...rest.positioning, gutter: 4 }}>
|
||||
<ChakraPopover.Trigger asChild>{children}</ChakraPopover.Trigger>
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraPopover.Positioner>
|
||||
<ChakraPopover.Content width='auto' px='2' py='1' textStyle='xs' rounded='sm' ref={ref} {...contentProps}>
|
||||
{showArrow && (
|
||||
<ChakraPopover.Arrow>
|
||||
<ChakraPopover.ArrowTip />
|
||||
</ChakraPopover.Arrow>
|
||||
)}
|
||||
{content}
|
||||
</ChakraPopover.Content>
|
||||
</ChakraPopover.Positioner>
|
||||
</Portal>
|
||||
</ChakraPopover.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export interface InfoTipProps extends Partial<ToggleTipProps> {
|
||||
buttonProps?: IconButtonProps | undefined;
|
||||
}
|
||||
|
||||
export const InfoTip = React.forwardRef<HTMLDivElement, InfoTipProps>(function InfoTip(props, ref) {
|
||||
const { children, buttonProps, ...rest } = props;
|
||||
return (
|
||||
<ToggleTip content={children} {...rest} ref={ref}>
|
||||
<IconButton variant='ghost' aria-label='info' size='2xs' colorPalette='gray' {...buttonProps}>
|
||||
<HiOutlineInformationCircle />
|
||||
</IconButton>
|
||||
</ToggleTip>
|
||||
);
|
||||
});
|
||||
35
src/components/ui/overlays/tooltip.tsx
Normal file
35
src/components/ui/overlays/tooltip.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Tooltip as ChakraTooltip, Portal } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface TooltipProps extends ChakraTooltip.RootProps {
|
||||
showArrow?: boolean;
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
content: React.ReactNode;
|
||||
contentProps?: ChakraTooltip.ContentProps;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(function Tooltip(props, ref) {
|
||||
const { showArrow, children, disabled, portalled = true, content, contentProps, portalRef, ...rest } = props;
|
||||
|
||||
if (disabled) return children;
|
||||
|
||||
return (
|
||||
<ChakraTooltip.Root {...rest}>
|
||||
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraTooltip.Positioner>
|
||||
<ChakraTooltip.Content ref={ref} {...contentProps}>
|
||||
{showArrow && (
|
||||
<ChakraTooltip.Arrow>
|
||||
<ChakraTooltip.ArrowTip />
|
||||
</ChakraTooltip.Arrow>
|
||||
)}
|
||||
{content}
|
||||
</ChakraTooltip.Content>
|
||||
</ChakraTooltip.Positioner>
|
||||
</Portal>
|
||||
</ChakraTooltip.Root>
|
||||
);
|
||||
});
|
||||
23
src/components/ui/provider.tsx
Normal file
23
src/components/ui/provider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode";
|
||||
import { system } from "../../theme/theme";
|
||||
import { Toaster } from "./feedback/toaster";
|
||||
import TopLoader from "./top-loader";
|
||||
import ReactQueryProvider from "@/provider/react-query-provider";
|
||||
|
||||
export function Provider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ReactQueryProvider>
|
||||
<ChakraProvider value={system}>
|
||||
<TopLoader />
|
||||
<ColorModeProvider {...props} />
|
||||
<Toaster />
|
||||
</ChakraProvider>
|
||||
</ReactQueryProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
10
src/components/ui/top-loader.tsx
Normal file
10
src/components/ui/top-loader.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { useToken } from '@chakra-ui/react';
|
||||
|
||||
export default function TopLoader() {
|
||||
const [color] = useToken('colors', ['primary.500']);
|
||||
|
||||
return <NextTopLoader color={color} showSpinner={false} />;
|
||||
}
|
||||
27
src/components/ui/typography/blockquote.tsx
Normal file
27
src/components/ui/typography/blockquote.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Blockquote as ChakraBlockquote } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface BlockquoteProps extends ChakraBlockquote.RootProps {
|
||||
cite?: React.ReactNode;
|
||||
citeUrl?: string;
|
||||
icon?: React.ReactNode;
|
||||
showDash?: boolean;
|
||||
}
|
||||
|
||||
export const Blockquote = React.forwardRef<HTMLDivElement, BlockquoteProps>(function Blockquote(props, ref) {
|
||||
const { children, cite, citeUrl, showDash, icon, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ChakraBlockquote.Root ref={ref} {...rest}>
|
||||
{icon}
|
||||
<ChakraBlockquote.Content cite={citeUrl}>{children}</ChakraBlockquote.Content>
|
||||
{cite && (
|
||||
<ChakraBlockquote.Caption>
|
||||
{showDash ? <>—</> : null} <cite>{cite}</cite>
|
||||
</ChakraBlockquote.Caption>
|
||||
)}
|
||||
</ChakraBlockquote.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export const BlockquoteIcon = ChakraBlockquote.Icon;
|
||||
275
src/components/ui/typography/prose.tsx
Normal file
275
src/components/ui/typography/prose.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { chakra } from '@chakra-ui/react';
|
||||
|
||||
const TRAILING_PSEUDO_REGEX = /(::?[\w-]+(?:\([^)]*\))?)+$/;
|
||||
const EXCLUDE_CLASSNAME = '.not-prose';
|
||||
function inWhere<T extends string>(selector: T): T {
|
||||
const rebuiltSelector = selector.startsWith('& ') ? selector.slice(2) : selector;
|
||||
const match = selector.match(TRAILING_PSEUDO_REGEX);
|
||||
const pseudo = match ? match[0] : '';
|
||||
const base = match ? selector.slice(0, -match[0].length) : rebuiltSelector;
|
||||
return `& :where(${base}):not(${EXCLUDE_CLASSNAME}, ${EXCLUDE_CLASSNAME} *)${pseudo}` as T;
|
||||
}
|
||||
|
||||
export const Prose = chakra('div', {
|
||||
base: {
|
||||
color: 'fg.muted',
|
||||
maxWidth: '65ch',
|
||||
fontSize: 'sm',
|
||||
lineHeight: '1.7em',
|
||||
[inWhere('& p')]: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
},
|
||||
[inWhere('& blockquote')]: {
|
||||
marginTop: '1.285em',
|
||||
marginBottom: '1.285em',
|
||||
paddingInline: '1.285em',
|
||||
borderInlineStartWidth: '0.25em',
|
||||
color: 'fg',
|
||||
},
|
||||
[inWhere('& a')]: {
|
||||
color: 'fg',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '3px',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationColor: 'border.muted',
|
||||
fontWeight: '500',
|
||||
},
|
||||
[inWhere('& strong')]: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
[inWhere('& a strong')]: {
|
||||
color: 'inherit',
|
||||
},
|
||||
[inWhere('& h1')]: {
|
||||
fontSize: '2.15em',
|
||||
letterSpacing: '-0.02em',
|
||||
marginTop: '0',
|
||||
marginBottom: '0.8em',
|
||||
lineHeight: '1.2em',
|
||||
},
|
||||
[inWhere('& h2')]: {
|
||||
fontSize: '1.4em',
|
||||
letterSpacing: '-0.02em',
|
||||
marginTop: '1.6em',
|
||||
marginBottom: '0.8em',
|
||||
lineHeight: '1.4em',
|
||||
},
|
||||
[inWhere('& h3')]: {
|
||||
fontSize: '1.285em',
|
||||
letterSpacing: '-0.01em',
|
||||
marginTop: '1.5em',
|
||||
marginBottom: '0.4em',
|
||||
lineHeight: '1.5em',
|
||||
},
|
||||
[inWhere('& h4')]: {
|
||||
marginTop: '1.4em',
|
||||
marginBottom: '0.5em',
|
||||
letterSpacing: '-0.01em',
|
||||
lineHeight: '1.5em',
|
||||
},
|
||||
[inWhere('& img')]: {
|
||||
marginTop: '1.7em',
|
||||
marginBottom: '1.7em',
|
||||
borderRadius: 'lg',
|
||||
boxShadow: 'inset',
|
||||
},
|
||||
[inWhere('& picture')]: {
|
||||
marginTop: '1.7em',
|
||||
marginBottom: '1.7em',
|
||||
},
|
||||
[inWhere('& picture > img')]: {
|
||||
marginTop: '0',
|
||||
marginBottom: '0',
|
||||
},
|
||||
[inWhere('& video')]: {
|
||||
marginTop: '1.7em',
|
||||
marginBottom: '1.7em',
|
||||
},
|
||||
[inWhere('& kbd')]: {
|
||||
fontSize: '0.85em',
|
||||
borderRadius: 'xs',
|
||||
paddingTop: '0.15em',
|
||||
paddingBottom: '0.15em',
|
||||
paddingInlineEnd: '0.35em',
|
||||
paddingInlineStart: '0.35em',
|
||||
fontFamily: 'inherit',
|
||||
color: 'fg.muted',
|
||||
'--shadow': 'colors.border',
|
||||
boxShadow: '0 0 0 1px var(--shadow),0 1px 0 1px var(--shadow)',
|
||||
},
|
||||
[inWhere('& code')]: {
|
||||
fontSize: '0.925em',
|
||||
letterSpacing: '-0.01em',
|
||||
borderRadius: 'md',
|
||||
borderWidth: '1px',
|
||||
padding: '0.25em',
|
||||
},
|
||||
[inWhere('& pre code')]: {
|
||||
fontSize: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
borderWidth: 'inherit',
|
||||
padding: '0',
|
||||
},
|
||||
[inWhere('& h2 code')]: {
|
||||
fontSize: '0.9em',
|
||||
},
|
||||
[inWhere('& h3 code')]: {
|
||||
fontSize: '0.8em',
|
||||
},
|
||||
[inWhere('& pre')]: {
|
||||
backgroundColor: 'bg.subtle',
|
||||
marginTop: '1.6em',
|
||||
marginBottom: '1.6em',
|
||||
borderRadius: 'md',
|
||||
fontSize: '0.9em',
|
||||
paddingTop: '0.65em',
|
||||
paddingBottom: '0.65em',
|
||||
paddingInlineEnd: '1em',
|
||||
paddingInlineStart: '1em',
|
||||
overflowX: 'auto',
|
||||
fontWeight: '400',
|
||||
},
|
||||
[inWhere('& ol')]: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
paddingInlineStart: '1.5em',
|
||||
},
|
||||
[inWhere('& ul')]: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
paddingInlineStart: '1.5em',
|
||||
},
|
||||
[inWhere('& li')]: {
|
||||
marginTop: '0.285em',
|
||||
marginBottom: '0.285em',
|
||||
},
|
||||
[inWhere('& ol > li')]: {
|
||||
paddingInlineStart: '0.4em',
|
||||
listStyleType: 'decimal',
|
||||
'&::marker': {
|
||||
color: 'fg.muted',
|
||||
},
|
||||
},
|
||||
[inWhere('& ul > li')]: {
|
||||
paddingInlineStart: '0.4em',
|
||||
listStyleType: 'disc',
|
||||
'&::marker': {
|
||||
color: 'fg.muted',
|
||||
},
|
||||
},
|
||||
[inWhere('& > ul > li p')]: {
|
||||
marginTop: '0.5em',
|
||||
marginBottom: '0.5em',
|
||||
},
|
||||
[inWhere('& > ul > li > p:first-of-type')]: {
|
||||
marginTop: '1em',
|
||||
},
|
||||
[inWhere('& > ul > li > p:last-of-type')]: {
|
||||
marginBottom: '1em',
|
||||
},
|
||||
[inWhere('& > ol > li > p:first-of-type')]: {
|
||||
marginTop: '1em',
|
||||
},
|
||||
[inWhere('& > ol > li > p:last-of-type')]: {
|
||||
marginBottom: '1em',
|
||||
},
|
||||
[inWhere('& ul ul, ul ol, ol ul, ol ol')]: {
|
||||
marginTop: '0.5em',
|
||||
marginBottom: '0.5em',
|
||||
},
|
||||
[inWhere('& dl')]: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
},
|
||||
[inWhere('& dt')]: {
|
||||
fontWeight: '600',
|
||||
marginTop: '1em',
|
||||
},
|
||||
[inWhere('& dd')]: {
|
||||
marginTop: '0.285em',
|
||||
paddingInlineStart: '1.5em',
|
||||
},
|
||||
[inWhere('& hr')]: {
|
||||
marginTop: '2.25em',
|
||||
marginBottom: '2.25em',
|
||||
},
|
||||
[inWhere('& :is(h1,h2,h3,h4,h5,hr) + *')]: {
|
||||
marginTop: '0',
|
||||
},
|
||||
[inWhere('& table')]: {
|
||||
width: '100%',
|
||||
tableLayout: 'auto',
|
||||
textAlign: 'start',
|
||||
lineHeight: '1.5em',
|
||||
marginTop: '2em',
|
||||
marginBottom: '2em',
|
||||
},
|
||||
[inWhere('& thead')]: {
|
||||
borderBottomWidth: '1px',
|
||||
color: 'fg',
|
||||
},
|
||||
[inWhere('& tbody tr')]: {
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'border',
|
||||
},
|
||||
[inWhere('& thead th')]: {
|
||||
paddingInlineEnd: '1em',
|
||||
paddingBottom: '0.65em',
|
||||
paddingInlineStart: '1em',
|
||||
fontWeight: 'medium',
|
||||
textAlign: 'start',
|
||||
},
|
||||
[inWhere('& thead th:first-of-type')]: {
|
||||
paddingInlineStart: '0',
|
||||
},
|
||||
[inWhere('& thead th:last-of-type')]: {
|
||||
paddingInlineEnd: '0',
|
||||
},
|
||||
[inWhere('& tbody td, tfoot td')]: {
|
||||
paddingTop: '0.65em',
|
||||
paddingInlineEnd: '1em',
|
||||
paddingBottom: '0.65em',
|
||||
paddingInlineStart: '1em',
|
||||
},
|
||||
[inWhere('& tbody td:first-of-type, tfoot td:first-of-type')]: {
|
||||
paddingInlineStart: '0',
|
||||
},
|
||||
[inWhere('& tbody td:last-of-type, tfoot td:last-of-type')]: {
|
||||
paddingInlineEnd: '0',
|
||||
},
|
||||
[inWhere('& figure')]: {
|
||||
marginTop: '1.625em',
|
||||
marginBottom: '1.625em',
|
||||
},
|
||||
[inWhere('& figure > *')]: {
|
||||
marginTop: '0',
|
||||
marginBottom: '0',
|
||||
},
|
||||
[inWhere('& figcaption')]: {
|
||||
fontSize: '0.85em',
|
||||
lineHeight: '1.25em',
|
||||
marginTop: '0.85em',
|
||||
color: 'fg.muted',
|
||||
},
|
||||
[inWhere('& h1, h2, h3, h4')]: {
|
||||
color: 'fg',
|
||||
fontWeight: '600',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
md: {
|
||||
fontSize: 'sm',
|
||||
},
|
||||
lg: {
|
||||
fontSize: 'md',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
24
src/config/auth.ts
Normal file
24
src/config/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Auth Configuration
|
||||
* Controls whether authentication is required for the app
|
||||
*/
|
||||
|
||||
export const authConfig = {
|
||||
// If true, users must login to access the app
|
||||
// If false, app is public with optional login
|
||||
isAuthRequired: process.env.NEXT_PUBLIC_AUTH_REQUIRED === "true",
|
||||
|
||||
// Public routes that don't require authentication (when auth is required)
|
||||
publicRoutes: ["/signin", "/signup", "/forgot-password"],
|
||||
|
||||
// Routes that should always be protected (even when auth is optional)
|
||||
protectedRoutes: ["/admin", "/settings", "/profile"],
|
||||
};
|
||||
|
||||
export const isPublicRoute = (pathname: string): boolean => {
|
||||
return authConfig.publicRoutes.some((route) => pathname.includes(route));
|
||||
};
|
||||
|
||||
export const isProtectedRoute = (pathname: string): boolean => {
|
||||
return authConfig.protectedRoutes.some((route) => pathname.includes(route));
|
||||
};
|
||||
15
src/config/base-url.ts
Normal file
15
src/config/base-url.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const isServer = typeof window === "undefined";
|
||||
|
||||
const apiUrl = isServer
|
||||
? process.env.SERVER_API_URL || "http://localhost:3000/api"
|
||||
: process.env.NEXT_PUBLIC_API_URL || "/api/backend";
|
||||
|
||||
const baseUrl = {
|
||||
// Main API Endpoint (Backend)
|
||||
// Logic: Server uses direct HTTP, Client uses Proxy to avoid Mixed Content
|
||||
auth: apiUrl,
|
||||
admin: apiUrl,
|
||||
core: apiUrl,
|
||||
};
|
||||
|
||||
export default baseUrl;
|
||||
14
src/config/navigation.ts
Normal file
14
src/config/navigation.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
protected?: boolean;
|
||||
public?: boolean;
|
||||
onlyPublic?: boolean;
|
||||
visible?: boolean;
|
||||
children?: NavItem[];
|
||||
};
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ label: "home", href: "/home", public: true },
|
||||
{ label: "predictions", href: "/predictions", public: true },
|
||||
];
|
||||
490
src/data/constants.ts
Normal file
490
src/data/constants.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { ButtonProps } from '@/components/ui/buttons/button';
|
||||
import { ColorPickerColorFormat } from '@chakra-ui/react';
|
||||
|
||||
export const colorPalettes: ButtonProps['colorPalette'][] = [
|
||||
'current',
|
||||
'blue',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'gray',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'cyan',
|
||||
];
|
||||
export const variants: ButtonProps['variant'][] = ['subtle', 'outline', 'ghost', 'solid', 'plain', 'surface'];
|
||||
export const fontSizes: ButtonProps['fontSize'][] = [
|
||||
'2xs',
|
||||
'xs',
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'xl',
|
||||
'2xl',
|
||||
'3xl',
|
||||
'4xl',
|
||||
'5xl',
|
||||
'6xl',
|
||||
'7xl',
|
||||
'8xl',
|
||||
'9xl',
|
||||
];
|
||||
|
||||
export const boxData = {
|
||||
imageUrl: 'https://bit.ly/2Z4KKcF',
|
||||
imageAlt: 'Rear view of modern home with pool',
|
||||
beds: 3,
|
||||
title: 'Modern home in city center in the heart of historic Los Angeles',
|
||||
formattedPrice: '$435',
|
||||
reviewCount: 34,
|
||||
rating: 4.5,
|
||||
};
|
||||
|
||||
export const alignments = ['flex-start', 'center', 'flex-end'] as const;
|
||||
export const justifications = [
|
||||
'flex-start',
|
||||
'center',
|
||||
'flex-end',
|
||||
'space-between',
|
||||
'space-around',
|
||||
'space-evenly',
|
||||
] as const;
|
||||
|
||||
export const placements = [
|
||||
'bottom-end',
|
||||
'bottom-start',
|
||||
'top-end',
|
||||
'top-start',
|
||||
'bottom-center',
|
||||
'top-center',
|
||||
'middle-center',
|
||||
'middle-end',
|
||||
'middle-start',
|
||||
] as const;
|
||||
|
||||
export const headings = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl'] as const;
|
||||
|
||||
export const swatches = ['red', 'green', 'blue', 'purple', 'orange', 'pink'] as const;
|
||||
|
||||
export const formats: ColorPickerColorFormat[] = ['hsla', 'hsba', 'rgba'];
|
||||
|
||||
export const MAX_FILES = 3;
|
||||
|
||||
export const radioCardItems = [
|
||||
{ value: 'next', title: 'Next.js' },
|
||||
{ value: 'vite', title: 'Vite' },
|
||||
];
|
||||
|
||||
export const radioCardItems2 = [
|
||||
{ value: 'next', title: 'Next.js', description: 'Best for apps' },
|
||||
{ value: 'vite', title: 'Vite', description: 'Best for SPAs' },
|
||||
{ value: 'remix', title: 'Remix', description: 'Best for server-side rendering' },
|
||||
];
|
||||
|
||||
export const marks = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 90, label: '90' },
|
||||
];
|
||||
|
||||
export const frameworks = [
|
||||
{ label: 'React', value: 'react' },
|
||||
{ label: 'Solid', value: 'solid' },
|
||||
{ label: 'Vue', value: 'vue' },
|
||||
{ label: 'Angular', value: 'angular' },
|
||||
{ label: 'Svelte', value: 'svelte' },
|
||||
{ label: 'Preact', value: 'preact' },
|
||||
{ label: 'Qwik', value: 'qwik' },
|
||||
{ label: 'Lit', value: 'lit' },
|
||||
{ label: 'Alpine.js', value: 'alpinejs' },
|
||||
{ label: 'Ember', value: 'ember' },
|
||||
{ label: 'Next.js', value: 'nextjs' },
|
||||
];
|
||||
|
||||
export const countries = [
|
||||
{ value: 'AD', label: 'Andorra', emoji: '🇦🇩' },
|
||||
{ value: 'AE', label: 'United Arab Emirates', emoji: '🇦🇪' },
|
||||
{ value: 'AF', label: 'Afghanistan', emoji: '🇦🇫' },
|
||||
{ value: 'AG', label: 'Antigua and Barbuda', emoji: '🇦🇬' },
|
||||
{ value: 'AI', label: 'Anguilla', emoji: '🇦🇮' },
|
||||
{ value: 'AL', label: 'Albania', emoji: '🇦🇱' },
|
||||
{ value: 'AM', label: 'Armenia', emoji: '🇦🇲' },
|
||||
{ value: 'AO', label: 'Angola', emoji: '🇦🇴' },
|
||||
{ value: 'AQ', label: 'Antarctica', emoji: '🇦🇶' },
|
||||
{ value: 'AR', label: 'Argentina', emoji: '🇦🇷' },
|
||||
{ value: 'AS', label: 'American Samoa', emoji: '🇦🇸' },
|
||||
{ value: 'AT', label: 'Austria', emoji: '🇦🇹' },
|
||||
{ value: 'AU', label: 'Australia', emoji: '🇦🇺' },
|
||||
{ value: 'AW', label: 'Aruba', emoji: '🇦🇼' },
|
||||
{ value: 'AX', label: 'Åland Islands', emoji: '🇦🇽' },
|
||||
{ value: 'AZ', label: 'Azerbaijan', emoji: '🇦🇿' },
|
||||
{ value: 'BA', label: 'Bosnia and Herzegovina', emoji: '🇧🇦' },
|
||||
{ value: 'BB', label: 'Barbados', emoji: '🇧🇧' },
|
||||
{ value: 'BD', label: 'Bangladesh', emoji: '🇧🇩' },
|
||||
{ value: 'BE', label: 'Belgium', emoji: '🇧🇪' },
|
||||
{ value: 'BF', label: 'Burkina Faso', emoji: '🇧🇫' },
|
||||
{ value: 'BG', label: 'Bulgaria', emoji: '🇧🇬' },
|
||||
{ value: 'BH', label: 'Bahrain', emoji: '🇧🇭' },
|
||||
{ value: 'BI', label: 'Burundi', emoji: '🇧🇮' },
|
||||
{ value: 'BJ', label: 'Benin', emoji: '🇧🇯' },
|
||||
{ value: 'BL', label: 'Saint Barthélemy', emoji: '🇧🇱' },
|
||||
{ value: 'BM', label: 'Bermuda', emoji: '🇧🇲' },
|
||||
{ value: 'BN', label: 'Brunei Darussalam', emoji: '🇧🇳' },
|
||||
{ value: 'BO', label: 'Bolivia, Plurinational State of', emoji: '🇧🇴' },
|
||||
{ value: 'BQ', label: 'Bonaire, Sint Eustatius and Saba', emoji: '🇧🇶' },
|
||||
{ value: 'BR', label: 'Brazil', emoji: '🇧🇷' },
|
||||
{ value: 'BS', label: 'Bahamas', emoji: '🇧🇸' },
|
||||
{ value: 'BT', label: 'Bhutan', emoji: '🇧🇹' },
|
||||
{ value: 'BV', label: 'Bouvet Island', emoji: '🇧🇻' },
|
||||
{ value: 'BW', label: 'Botswana', emoji: '🇧🇼' },
|
||||
{ value: 'BY', label: 'Belarus', emoji: '🇧🇾' },
|
||||
{ value: 'BZ', label: 'Belize', emoji: '🇧🇿' },
|
||||
{ value: 'CA', label: 'Canada', emoji: '🇨🇦' },
|
||||
{ value: 'CC', label: 'Cocos (Keeling) Islands', emoji: '🇨🇨' },
|
||||
{ value: 'CD', label: 'Congo, Democratic Republic of the', emoji: '🇨🇩' },
|
||||
{ value: 'CF', label: 'Central African Republic', emoji: '🇨🇫' },
|
||||
{ value: 'CG', label: 'Congo', emoji: '🇨🇬' },
|
||||
{ value: 'CH', label: 'Switzerland', emoji: '🇨🇭' },
|
||||
{ value: 'CI', label: "Côte d'Ivoire", emoji: '🇨🇮' },
|
||||
{ value: 'CK', label: 'Cook Islands', emoji: '🇨🇰' },
|
||||
{ value: 'CL', label: 'Chile', emoji: '🇨🇱' },
|
||||
{ value: 'CM', label: 'Cameroon', emoji: '🇨🇲' },
|
||||
{ value: 'CN', label: 'China', emoji: '🇨🇳' },
|
||||
{ value: 'CO', label: 'Colombia', emoji: '🇨🇴' },
|
||||
{ value: 'CR', label: 'Costa Rica', emoji: '🇨🇷' },
|
||||
{ value: 'CU', label: 'Cuba', emoji: '🇨🇺' },
|
||||
{ value: 'CV', label: 'Cabo Verde', emoji: '🇨🇻' },
|
||||
{ value: 'CW', label: 'Curaçao', emoji: '🇨🇼' },
|
||||
{ value: 'CX', label: 'Christmas Island', emoji: '🇨🇽' },
|
||||
{ value: 'CY', label: 'Cyprus', emoji: '🇨🇾' },
|
||||
{ value: 'CZ', label: 'Czechia', emoji: '🇨🇿' },
|
||||
{ value: 'DE', label: 'Germany', emoji: '🇩🇪' },
|
||||
{ value: 'DJ', label: 'Djibouti', emoji: '🇩🇯' },
|
||||
{ value: 'DK', label: 'Denmark', emoji: '🇩🇰' },
|
||||
{ value: 'DM', label: 'Dominica', emoji: '🇩🇲' },
|
||||
{ value: 'DO', label: 'Dominican Republic', emoji: '🇩🇴' },
|
||||
{ value: 'DZ', label: 'Algeria', emoji: '🇩🇿' },
|
||||
{ value: 'EC', label: 'Ecuador', emoji: '🇪🇨' },
|
||||
{ value: 'EE', label: 'Estonia', emoji: '🇪🇪' },
|
||||
{ value: 'EG', label: 'Egypt', emoji: '🇪🇬' },
|
||||
{ value: 'EH', label: 'Western Sahara', emoji: '🇪🇭' },
|
||||
{ value: 'ER', label: 'Eritrea', emoji: '🇪🇷' },
|
||||
{ value: 'ES', label: 'Spain', emoji: '🇪🇸' },
|
||||
{ value: 'ET', label: 'Ethiopia', emoji: '🇪🇹' },
|
||||
{ value: 'FI', label: 'Finland', emoji: '🇫🇮' },
|
||||
{ value: 'FJ', label: 'Fiji', emoji: '🇫🇯' },
|
||||
{ value: 'FK', label: 'Falkland Islands (Malvinas)', emoji: '🇫🇰' },
|
||||
{ value: 'FM', label: 'Micronesia, Federated States of', emoji: '🇫🇲' },
|
||||
{ value: 'FO', label: 'Faroe Islands', emoji: '🇫🇴' },
|
||||
{ value: 'FR', label: 'France', emoji: '🇫🇷' },
|
||||
{ value: 'GA', label: 'Gabon', emoji: '🇬🇦' },
|
||||
{
|
||||
value: 'GB',
|
||||
label: 'United Kingdom of Great Britain and Northern Ireland',
|
||||
emoji: '🇬🇧',
|
||||
},
|
||||
{ value: 'GD', label: 'Grenada', emoji: '🇬🇩' },
|
||||
{ value: 'GE', label: 'Georgia', emoji: '🇬🇪' },
|
||||
{ value: 'GF', label: 'French Guiana', emoji: '🇬🇫' },
|
||||
{ value: 'GG', label: 'Guernsey', emoji: '🇬🇬' },
|
||||
{ value: 'GH', label: 'Ghana', emoji: '🇬🇭' },
|
||||
{ value: 'GI', label: 'Gibraltar', emoji: '🇬🇮' },
|
||||
{ value: 'GL', label: 'Greenland', emoji: '🇬🇱' },
|
||||
{ value: 'GM', label: 'Gambia', emoji: '🇬🇲' },
|
||||
{ value: 'GN', label: 'Guinea', emoji: '🇬🇳' },
|
||||
{ value: 'GP', label: 'Guadeloupe', emoji: '🇬🇵' },
|
||||
{ value: 'GQ', label: 'Equatorial Guinea', emoji: '🇬🇶' },
|
||||
{ value: 'GR', label: 'Greece', emoji: '🇬🇷' },
|
||||
{
|
||||
value: 'GS',
|
||||
label: 'South Georgia and the South Sandwich Islands',
|
||||
emoji: '🇬🇸',
|
||||
},
|
||||
{ value: 'GT', label: 'Guatemala', emoji: '🇬🇹' },
|
||||
{ value: 'GU', label: 'Guam', emoji: '🇬🇺' },
|
||||
{ value: 'GW', label: 'Guinea-Bissau', emoji: '🇬🇼' },
|
||||
{ value: 'GY', label: 'Guyana', emoji: '🇬🇾' },
|
||||
{ value: 'HK', label: 'Hong Kong', emoji: '🇭🇰' },
|
||||
{ value: 'HM', label: 'Heard Island and McDonald Islands', emoji: '🇭🇲' },
|
||||
{ value: 'HN', label: 'Honduras', emoji: '🇭🇳' },
|
||||
{ value: 'HR', label: 'Croatia', emoji: '🇭🇷' },
|
||||
{ value: 'HT', label: 'Haiti', emoji: '🇭🇹' },
|
||||
{ value: 'HU', label: 'Hungary', emoji: '🇭🇺' },
|
||||
{ value: 'ID', label: 'Indonesia', emoji: '🇮🇩' },
|
||||
{ value: 'IE', label: 'Ireland', emoji: '🇮🇪' },
|
||||
{ value: 'IL', label: 'Israel', emoji: '🇮🇱' },
|
||||
{ value: 'IM', label: 'Isle of Man', emoji: '🇮🇲' },
|
||||
{ value: 'IN', label: 'India', emoji: '🇮🇳' },
|
||||
{ value: 'IO', label: 'British Indian Ocean Territory', emoji: '🇮🇴' },
|
||||
{ value: 'IQ', label: 'Iraq', emoji: '🇮🇶' },
|
||||
{ value: 'IR', label: 'Iran, Islamic Republic of', emoji: '🇮🇷' },
|
||||
{ value: 'IS', label: 'Iceland', emoji: '🇮🇸' },
|
||||
{ value: 'IT', label: 'Italy', emoji: '🇮🇹' },
|
||||
{ value: 'JE', label: 'Jersey', emoji: '🇯🇪' },
|
||||
{ value: 'JM', label: 'Jamaica', emoji: '🇯🇲' },
|
||||
{ value: 'JO', label: 'Jordan', emoji: '🇯🇴' },
|
||||
{ value: 'JP', label: 'Japan', emoji: '🇯🇵' },
|
||||
{ value: 'KE', label: 'Kenya', emoji: '🇰🇪' },
|
||||
{ value: 'KG', label: 'Kyrgyzstan', emoji: '🇰🇬' },
|
||||
{ value: 'KH', label: 'Cambodia', emoji: '🇰🇭' },
|
||||
{ value: 'KI', label: 'Kiribati', emoji: '🇰🇮' },
|
||||
{ value: 'KM', label: 'Comoros', emoji: '🇰🇲' },
|
||||
{ value: 'KN', label: 'Saint Kitts and Nevis', emoji: '🇰🇳' },
|
||||
{ value: 'KP', label: "Korea, Democratic People's Republic of", emoji: '🇰🇵' },
|
||||
{ value: 'KR', label: 'Korea, Republic of', emoji: '🇰🇷' },
|
||||
{ value: 'KW', label: 'Kuwait', emoji: '🇰🇼' },
|
||||
{ value: 'KY', label: 'Cayman Islands', emoji: '🇰🇾' },
|
||||
{ value: 'KZ', label: 'Kazakhstan', emoji: '🇰🇿' },
|
||||
{ value: 'LA', label: "Lao People's Democratic Republic", emoji: '🇱🇦' },
|
||||
{ value: 'LB', label: 'Lebanon', emoji: '🇱🇧' },
|
||||
{ value: 'LC', label: 'Saint Lucia', emoji: '🇱🇨' },
|
||||
{ value: 'LI', label: 'Liechtenstein', emoji: '🇱🇮' },
|
||||
{ value: 'LK', label: 'Sri Lanka', emoji: '🇱🇰' },
|
||||
{ value: 'LR', label: 'Liberia', emoji: '🇱🇷' },
|
||||
{ value: 'LS', label: 'Lesotho', emoji: '🇱🇸' },
|
||||
{ value: 'LT', label: 'Lithuania', emoji: '🇱🇹' },
|
||||
{ value: 'LU', label: 'Luxembourg', emoji: '🇱🇺' },
|
||||
{ value: 'LV', label: 'Latvia', emoji: '🇱🇻' },
|
||||
{ value: 'LY', label: 'Libya', emoji: '🇱🇾' },
|
||||
{ value: 'MA', label: 'Morocco', emoji: '🇲🇦' },
|
||||
{ value: 'MC', label: 'Monaco', emoji: '🇲🇨' },
|
||||
{ value: 'MD', label: 'Moldova, Republic of', emoji: '🇲🇩' },
|
||||
{ value: 'ME', label: 'Montenegro', emoji: '🇲🇪' },
|
||||
{ value: 'MF', label: 'Saint Martin, (French part)', emoji: '🇲🇫' },
|
||||
{ value: 'MG', label: 'Madagascar', emoji: '🇲🇬' },
|
||||
{ value: 'MH', label: 'Marshall Islands', emoji: '🇲🇭' },
|
||||
{ value: 'MK', label: 'North Macedonia', emoji: '🇲🇰' },
|
||||
{ value: 'ML', label: 'Mali', emoji: '🇲🇱' },
|
||||
{ value: 'MM', label: 'Myanmar', emoji: '🇲🇲' },
|
||||
{ value: 'MN', label: 'Mongolia', emoji: '🇲🇳' },
|
||||
{ value: 'MO', label: 'Macao', emoji: '🇲🇴' },
|
||||
{ value: 'MP', label: 'Northern Mariana Islands', emoji: '🇲🇵' },
|
||||
{ value: 'MQ', label: 'Martinique', emoji: '🇲🇶' },
|
||||
{ value: 'MR', label: 'Mauritania', emoji: '🇲🇷' },
|
||||
{ value: 'MS', label: 'Montserrat', emoji: '🇲🇸' },
|
||||
{ value: 'MT', label: 'Malta', emoji: '🇲🇹' },
|
||||
{ value: 'MU', label: 'Mauritius', emoji: '🇲🇺' },
|
||||
{ value: 'MV', label: 'Maldives', emoji: '🇲🇻' },
|
||||
{ value: 'MW', label: 'Malawi', emoji: '🇲🇼' },
|
||||
{ value: 'MX', label: 'Mexico', emoji: '🇲🇽' },
|
||||
{ value: 'MY', label: 'Malaysia', emoji: '🇲🇾' },
|
||||
{ value: 'MZ', label: 'Mozambique', emoji: '🇲🇿' },
|
||||
{ value: 'NA', label: 'Namibia', emoji: '🇳🇦' },
|
||||
{ value: 'NC', label: 'New Caledonia', emoji: '🇳🇨' },
|
||||
{ value: 'NE', label: 'Niger', emoji: '🇳🇪' },
|
||||
{ value: 'NF', label: 'Norfolk Island', emoji: '🇳🇫' },
|
||||
{ value: 'NG', label: 'Nigeria', emoji: '🇳🇬' },
|
||||
{ value: 'NI', label: 'Nicaragua', emoji: '🇳🇮' },
|
||||
{ value: 'NL', label: 'Netherlands', emoji: '🇳🇱' },
|
||||
{ value: 'NO', label: 'Norway', emoji: '🇳🇴' },
|
||||
{ value: 'NP', label: 'Nepal', emoji: '🇳🇵' },
|
||||
{ value: 'NR', label: 'Nauru', emoji: '🇳🇷' },
|
||||
{ value: 'NU', label: 'Niue', emoji: '🇳🇺' },
|
||||
{ value: 'NZ', label: 'New Zealand', emoji: '🇳🇿' },
|
||||
{ value: 'OM', label: 'Oman', emoji: '🇴🇲' },
|
||||
{ value: 'PA', label: 'Panama', emoji: '🇵🇦' },
|
||||
{ value: 'PE', label: 'Peru', emoji: '🇵🇪' },
|
||||
{ value: 'PF', label: 'French Polynesia', emoji: '🇵🇫' },
|
||||
{ value: 'PG', label: 'Papua New Guinea', emoji: '🇵🇬' },
|
||||
{ value: 'PH', label: 'Philippines', emoji: '🇵🇭' },
|
||||
{ value: 'PK', label: 'Pakistan', emoji: '🇵🇰' },
|
||||
{ value: 'PL', label: 'Poland', emoji: '🇵🇱' },
|
||||
{ value: 'PM', label: 'Saint Pierre and Miquelon', emoji: '🇵🇲' },
|
||||
{ value: 'PN', label: 'Pitcairn', emoji: '🇵🇳' },
|
||||
{ value: 'PR', label: 'Puerto Rico', emoji: '🇵🇷' },
|
||||
{ value: 'PS', label: 'Palestine, State of', emoji: '🇵🇸' },
|
||||
{ value: 'PT', label: 'Portugal', emoji: '🇵🇹' },
|
||||
{ value: 'PW', label: 'Palau', emoji: '🇵🇼' },
|
||||
{ value: 'PY', label: 'Paraguay', emoji: '🇵🇾' },
|
||||
{ value: 'QA', label: 'Qatar', emoji: '🇶🇦' },
|
||||
{ value: 'RE', label: 'Réunion', emoji: '🇷🇪' },
|
||||
{ value: 'RO', label: 'Romania', emoji: '🇷🇴' },
|
||||
{ value: 'RS', label: 'Serbia', emoji: '🇷🇸' },
|
||||
{ value: 'RU', label: 'Russian Federation', emoji: '🇷🇺' },
|
||||
{ value: 'RW', label: 'Rwanda', emoji: '🇷🇼' },
|
||||
{ value: 'SA', label: 'Saudi Arabia', emoji: '🇸🇦' },
|
||||
{ value: 'SB', label: 'Solomon Islands', emoji: '🇸🇧' },
|
||||
{ value: 'SC', label: 'Seychelles', emoji: '🇸🇨' },
|
||||
{ value: 'SD', label: 'Sudan', emoji: '🇸🇩' },
|
||||
{ value: 'SE', label: 'Sweden', emoji: '🇸🇪' },
|
||||
{ value: 'SG', label: 'Singapore', emoji: '🇸🇬' },
|
||||
{
|
||||
value: 'SH',
|
||||
label: 'Saint Helena, Ascension and Tristan da Cunha',
|
||||
emoji: '🇸🇭',
|
||||
},
|
||||
{ value: 'SI', label: 'Slovenia', emoji: '🇸🇮' },
|
||||
{ value: 'SJ', label: 'Svalbard and Jan Mayen', emoji: '🇸🇯' },
|
||||
{ value: 'SK', label: 'Slovakia', emoji: '🇸🇰' },
|
||||
{ value: 'SL', label: 'Sierra Leone', emoji: '🇸🇱' },
|
||||
{ value: 'SM', label: 'San Marino', emoji: '🇸🇲' },
|
||||
{ value: 'SN', label: 'Senegal', emoji: '🇸🇳' },
|
||||
{ value: 'SO', label: 'Somalia', emoji: '🇸🇴' },
|
||||
{ value: 'SR', label: 'Suriname', emoji: '🇸🇷' },
|
||||
{ value: 'SS', label: 'South Sudan', emoji: '🇸🇸' },
|
||||
{ value: 'ST', label: 'Sao Tome and Principe', emoji: '🇸🇹' },
|
||||
{ value: 'SV', label: 'El Salvador', emoji: '🇸🇻' },
|
||||
{ value: 'SX', label: 'Sint Maarten, (Dutch part)', emoji: '🇸🇽' },
|
||||
{ value: 'SY', label: 'Syrian Arab Republic', emoji: '🇸🇾' },
|
||||
{ value: 'SZ', label: 'Eswatini', emoji: '🇸🇿' },
|
||||
{ value: 'TC', label: 'Turks and Caicos Islands', emoji: '🇹🇨' },
|
||||
{ value: 'TD', label: 'Chad', emoji: '🇹🇩' },
|
||||
{ value: 'TF', label: 'French Southern Territories', emoji: '🇹🇫' },
|
||||
{ value: 'TG', label: 'Togo', emoji: '🇹🇬' },
|
||||
{ value: 'TH', label: 'Thailand', emoji: '🇹🇭' },
|
||||
{ value: 'TJ', label: 'Tajikistan', emoji: '🇹🇯' },
|
||||
{ value: 'TK', label: 'Tokelau', emoji: '🇹🇰' },
|
||||
{ value: 'TL', label: 'Timor-Leste', emoji: '🇹🇱' },
|
||||
{ value: 'TM', label: 'Turkmenistan', emoji: '🇹🇲' },
|
||||
{ value: 'TN', label: 'Tunisia', emoji: '🇹🇳' },
|
||||
{ value: 'TO', label: 'Tonga', emoji: '🇹🇴' },
|
||||
{ value: 'TR', label: 'Türkiye', emoji: '🇹🇷' },
|
||||
{ value: 'TT', label: 'Trinidad and Tobago', emoji: '🇹🇹' },
|
||||
{ value: 'TV', label: 'Tuvalu', emoji: '🇹🇻' },
|
||||
{ value: 'TW', label: 'Taiwan, Province of China', emoji: '🇹🇼' },
|
||||
{ value: 'TZ', label: 'Tanzania, United Republic of', emoji: '🇹🇿' },
|
||||
{ value: 'UA', label: 'Ukraine', emoji: '🇺🇦' },
|
||||
{ value: 'UG', label: 'Uganda', emoji: '🇺🇬' },
|
||||
{ value: 'UM', label: 'United States Minor Outlying Islands', emoji: '🇺🇲' },
|
||||
{ value: 'US', label: 'United States of America', emoji: '🇺🇸' },
|
||||
{ value: 'UY', label: 'Uruguay', emoji: '🇺🇾' },
|
||||
{ value: 'UZ', label: 'Uzbekistan', emoji: '🇺🇿' },
|
||||
{ value: 'VA', label: 'Holy See', emoji: '🇻🇦' },
|
||||
{ value: 'VC', label: 'Saint Vincent and the Grenadines', emoji: '🇻🇨' },
|
||||
{ value: 'VE', label: 'Venezuela, Bolivarian Republic of', emoji: '🇻🇪' },
|
||||
{ value: 'VG', label: 'Virgin Islands, British', emoji: '🇻🇬' },
|
||||
{ value: 'VI', label: 'Virgin Islands, U.S.', emoji: '🇻🇮' },
|
||||
{ value: 'VN', label: 'Viet Nam', emoji: '🇻🇳' },
|
||||
{ value: 'VU', label: 'Vanuatu', emoji: '🇻🇺' },
|
||||
{ value: 'WF', label: 'Wallis and Futuna', emoji: '🇼🇫' },
|
||||
{ value: 'WS', label: 'Samoa', emoji: '🇼🇸' },
|
||||
{ value: 'YE', label: 'Yemen', emoji: '🇾🇪' },
|
||||
{ value: 'YT', label: 'Mayotte', emoji: '🇾🇹' },
|
||||
{ value: 'ZA', label: 'South Africa', emoji: '🇿🇦' },
|
||||
{ value: 'ZM', label: 'Zambia', emoji: '🇿🇲' },
|
||||
{ value: 'ZW', label: 'Zimbabwe', emoji: '🇿🇼' },
|
||||
];
|
||||
|
||||
export const images = [
|
||||
{
|
||||
label: 'Mountain Landscape',
|
||||
value: 'mountains',
|
||||
description: 'Scenic mountain view',
|
||||
url: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop',
|
||||
},
|
||||
{
|
||||
label: 'Ocean Waves',
|
||||
value: 'ocean',
|
||||
description: 'Peaceful ocean scene',
|
||||
url: 'https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=400&h=300&fit=crop',
|
||||
},
|
||||
{
|
||||
label: 'Forest Path',
|
||||
value: 'forest',
|
||||
description: 'Tranquil forest trail',
|
||||
url: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=400&h=300&fit=crop',
|
||||
},
|
||||
{
|
||||
label: 'City Skyline',
|
||||
value: 'city',
|
||||
description: 'Urban cityscape at night',
|
||||
url: 'https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=400&h=300&fit=crop',
|
||||
},
|
||||
{
|
||||
label: 'Desert Dunes',
|
||||
value: 'desert',
|
||||
description: 'Golden sand dunes',
|
||||
url: 'https://images.unsplash.com/photo-1509316975850-ff9c5deb0cd9?w=400&h=300&fit=crop',
|
||||
},
|
||||
];
|
||||
|
||||
export const rootNode = {
|
||||
id: 'ROOT',
|
||||
name: '',
|
||||
children: [
|
||||
{
|
||||
id: 'node_modules',
|
||||
name: 'node_modules',
|
||||
children: [
|
||||
{ id: 'node_modules/zag-js', name: 'zag-js' },
|
||||
{ id: 'node_modules/pandacss', name: 'panda' },
|
||||
{
|
||||
id: 'node_modules/@types',
|
||||
name: '@types',
|
||||
children: [
|
||||
{ id: 'node_modules/@types/react', name: 'react' },
|
||||
{ id: 'node_modules/@types/react-dom', name: 'react-dom' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'src',
|
||||
name: 'src',
|
||||
children: [
|
||||
{ id: 'src/app.tsx', name: 'app.tsx' },
|
||||
{ id: 'src/index.ts', name: 'index.ts' },
|
||||
],
|
||||
},
|
||||
{ id: 'panda.config', name: 'panda.config.ts' },
|
||||
{ id: 'package.json', name: 'package.json' },
|
||||
{ id: 'renovate.json', name: 'renovate.json' },
|
||||
{ id: 'readme.md', name: 'README.md' },
|
||||
],
|
||||
};
|
||||
|
||||
export const radioItems = [
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
];
|
||||
|
||||
export const alertStatuses = ['success', 'warning', 'error', 'info', 'neutral'] as const;
|
||||
|
||||
export const stats = [
|
||||
{ label: 'New Users', value: '234', diff: -12, helpText: 'Till date' },
|
||||
{ label: 'Sales', value: '£12,340', diff: 12, helpText: 'Last 30 days' },
|
||||
{ label: 'Revenue', value: '3,450', diff: 4.5, helpText: 'Last 30 days' },
|
||||
];
|
||||
|
||||
export const items = [
|
||||
{ id: 1, name: 'Laptop', category: 'Electronics', price: 999.99 },
|
||||
{ id: 2, name: 'Coffee Maker', category: 'Home Appliances', price: 49.99 },
|
||||
{ id: 3, name: 'Desk Chair', category: 'Furniture', price: 150.0 },
|
||||
{ id: 4, name: 'Smartphone', category: 'Electronics', price: 799.99 },
|
||||
{ id: 5, name: 'Headphones', category: 'Accessories', price: 199.99 },
|
||||
];
|
||||
|
||||
export const accordionItems = [
|
||||
{ value: 'a', title: 'First Item', text: 'Some value 1...' },
|
||||
{ value: 'b', title: 'Second Item', text: 'Some value 2...' },
|
||||
{ value: 'c', title: 'Third Item', text: 'Some value 3...' },
|
||||
];
|
||||
|
||||
export const breadcrumbItems = [
|
||||
{ href: '#', label: 'Home' },
|
||||
{ href: '#', label: 'Library' },
|
||||
{ href: '#', label: 'Data' },
|
||||
];
|
||||
|
||||
export const steps = [
|
||||
{
|
||||
title: 'Step 1',
|
||||
description: 'Step 1 description',
|
||||
},
|
||||
{
|
||||
title: 'Step 2',
|
||||
description: 'Step 2 description',
|
||||
},
|
||||
{
|
||||
title: 'Step 3',
|
||||
description: 'Step 3 description',
|
||||
},
|
||||
];
|
||||
|
||||
export const itemsTabs = [
|
||||
{ id: '1', title: 'Tab', content: 'Tab Content' },
|
||||
{ id: '2', title: 'Tab', content: 'Tab Content' },
|
||||
{ id: '3', title: 'Tab', content: 'Tab Content' },
|
||||
{ id: '4', title: 'Tab', content: 'Tab Content' },
|
||||
];
|
||||
26
src/hooks/useActiveNavItem.tsx
Normal file
26
src/hooks/useActiveNavItem.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { NavItem } from '@/config/navigation';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
export const useActiveNavItem = (item: NavItem) => {
|
||||
const path = usePathname();
|
||||
const locale = useLocale();
|
||||
const prefixedHref = `/${locale}${item.href}`;
|
||||
|
||||
const normalize = (url: string) => url.replace(/\/$/, '');
|
||||
const currentPath = normalize(path);
|
||||
const target = normalize(prefixedHref);
|
||||
|
||||
const hasActiveChild = item.children
|
||||
? item.children.some((child) => currentPath.startsWith(normalize(`/${locale}${child.href}`)))
|
||||
: false;
|
||||
|
||||
const isActive =
|
||||
target === `/${locale}` ? currentPath === `/${locale}` : currentPath.startsWith(target) || hasActiveChild;
|
||||
|
||||
const isChildActive = (href: string) => currentPath === `/${locale}${href}`;
|
||||
|
||||
return { isActive, isChildActive };
|
||||
};
|
||||
6
src/i18n/navigation.ts
Normal file
6
src/i18n/navigation.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createNavigation } from 'next-intl/navigation';
|
||||
import { routing } from './routing';
|
||||
|
||||
// Lightweight wrappers around Next.js' navigation
|
||||
// APIs that consider the routing configuration
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
|
||||
14
src/i18n/request.ts
Normal file
14
src/i18n/request.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { hasLocale } from 'next-intl';
|
||||
import { routing } from './routing';
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Typically corresponds to the `[locale]` segment
|
||||
const requested = await requestLocale;
|
||||
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
11
src/i18n/routing.ts
Normal file
11
src/i18n/routing.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
export const locales = ['en', 'tr'];
|
||||
export const defaultLocale = 'tr';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'always',
|
||||
localeCookie: true,
|
||||
});
|
||||
22
src/lib/api/api-service.ts
Normal file
22
src/lib/api/api-service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { clientMap } from '@/lib/api/client-map';
|
||||
import { Method } from 'axios';
|
||||
|
||||
interface ApiRequestOptions {
|
||||
url: string;
|
||||
client: keyof typeof clientMap;
|
||||
method?: Method;
|
||||
data?: any;
|
||||
params?: Record<string, any>;
|
||||
}
|
||||
|
||||
export async function apiRequest<T = any>(options: ApiRequestOptions): Promise<T> {
|
||||
const { url, client, method = 'get', data, params } = options;
|
||||
const clientInstance = clientMap[client];
|
||||
|
||||
if (!url || !clientInstance) {
|
||||
throw new Error(`Invalid API request: ${client} - ${url}`);
|
||||
}
|
||||
|
||||
const response = await clientInstance.request<T>({ method, url, data, params });
|
||||
return response.data;
|
||||
}
|
||||
9
src/lib/api/client-map.ts
Normal file
9
src/lib/api/client-map.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseUrl from '@/config/base-url';
|
||||
import { createApiClient } from '@/lib/api/create-api-client';
|
||||
import { AxiosInstance } from 'axios';
|
||||
|
||||
export const clientMap: Record<keyof typeof baseUrl, AxiosInstance> = {
|
||||
auth: createApiClient(baseUrl.auth!),
|
||||
admin: createApiClient(baseUrl.admin!),
|
||||
core: createApiClient(baseUrl.core!),
|
||||
};
|
||||
73
src/lib/api/create-api-client.ts
Normal file
73
src/lib/api/create-api-client.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { getSession, signOut } from 'next-auth/react';
|
||||
|
||||
// const MESSAGES = {
|
||||
// tr: {
|
||||
// title: 'Oturum Süresi Doldu',
|
||||
// description: 'Güvenliğiniz için çıkış yapıldı. Lütfen tekrar giriş yapınız.',
|
||||
// },
|
||||
// en: {
|
||||
// title: 'Session Expired',
|
||||
// description: 'You have been logged out for security reasons. Please log in again.',
|
||||
// },
|
||||
// };
|
||||
|
||||
export function createApiClient(baseURL: string): AxiosInstance {
|
||||
const client = axios.create({ baseURL });
|
||||
|
||||
// Helper to get locale from cookie or URL
|
||||
const getLocale = (): string => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Try to get from cookie first (NEXT_LOCALE is the default cookie name)
|
||||
const cookieLocale = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('NEXT_LOCALE='))
|
||||
?.split('=')[1];
|
||||
|
||||
if (cookieLocale) return cookieLocale;
|
||||
|
||||
// Fallback: get from URL path (e.g., /tr/generator -> tr)
|
||||
const pathLocale = window.location.pathname.split('/')[1];
|
||||
if (['en', 'tr', 'de'].includes(pathLocale)) return pathLocale;
|
||||
}
|
||||
return 'tr'; // Default locale
|
||||
};
|
||||
|
||||
client.interceptors.request.use(async (config) => {
|
||||
const session = await getSession();
|
||||
const token = session?.accessToken;
|
||||
|
||||
if (token) {
|
||||
config.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
// Set Accept-Language header based on current locale
|
||||
const locale = getLocale();
|
||||
config.headers.set('Accept-Language', locale);
|
||||
|
||||
if (!(config.data instanceof FormData)) {
|
||||
config.headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Zaten giriş sayfasında değilsek veya auth ile ilgili bir istek değilse çıkış yap
|
||||
const isAuthPath =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.pathname.includes('/api/auth') || window.location.pathname === '/');
|
||||
|
||||
if (!isAuthPath) {
|
||||
await signOut({ redirect: true, callbackUrl: '/' });
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
30
src/lib/api/example/admin/permissions/service.ts
Normal file
30
src/lib/api/example/admin/permissions/service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { apiRequest } from "@/lib/api/api-service";
|
||||
import { ApiResponse } from "@/types/api-response";
|
||||
import { PermissionResponseDto, CreatePermissionDto } from "./types";
|
||||
|
||||
/**
|
||||
* Admin Permissions Service - Example Implementation
|
||||
* Matches Backend: /api/admin/permissions/*
|
||||
*/
|
||||
|
||||
const getAll = () => {
|
||||
return apiRequest<ApiResponse<PermissionResponseDto[]>>({
|
||||
url: "/admin/permissions",
|
||||
client: "admin",
|
||||
method: "get",
|
||||
});
|
||||
};
|
||||
|
||||
const create = (data: CreatePermissionDto) => {
|
||||
return apiRequest<ApiResponse<PermissionResponseDto>>({
|
||||
url: "/admin/permissions",
|
||||
client: "admin",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export const adminPermissionsService = {
|
||||
getAll,
|
||||
create,
|
||||
};
|
||||
18
src/lib/api/example/admin/permissions/types.ts
Normal file
18
src/lib/api/example/admin/permissions/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Permission DTOs - Matches Backend
|
||||
export interface PermissionResponseDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Create Permission
|
||||
export interface CreatePermissionDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
}
|
||||
40
src/lib/api/example/admin/permissions/use-hooks.ts
Normal file
40
src/lib/api/example/admin/permissions/use-hooks.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiResponse } from "@/types/api-response";
|
||||
import { adminPermissionsService } from "./service";
|
||||
import { PermissionResponseDto, CreatePermissionDto } from "./types";
|
||||
|
||||
export const AdminPermissionsQueryKeys = {
|
||||
all: ["admin-permissions"] as const,
|
||||
list: () => [...AdminPermissionsQueryKeys.all, "list"] as const,
|
||||
};
|
||||
|
||||
export function useGetAllPermissions() {
|
||||
const queryKey = AdminPermissionsQueryKeys.list();
|
||||
|
||||
const { data, ...rest } = useQuery<ApiResponse<PermissionResponseDto[]>>({
|
||||
queryKey: queryKey,
|
||||
queryFn: adminPermissionsService.getAll,
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useCreatePermission() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<PermissionResponseDto>,
|
||||
Error,
|
||||
CreatePermissionDto
|
||||
>({
|
||||
mutationFn: (permissionData) =>
|
||||
adminPermissionsService.create(permissionData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AdminPermissionsQueryKeys.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
72
src/lib/api/example/admin/roles/service.ts
Normal file
72
src/lib/api/example/admin/roles/service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { apiRequest } from "@/lib/api/api-service";
|
||||
import { ApiResponse } from "@/types/api-response";
|
||||
import {
|
||||
RoleResponseDto,
|
||||
CreateRoleDto,
|
||||
UpdateRoleDto,
|
||||
RolePermissionResponseDto,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Admin Roles Service - Example Implementation
|
||||
* Matches Backend: /api/admin/roles/*
|
||||
*/
|
||||
|
||||
const getAll = () => {
|
||||
return apiRequest<ApiResponse<RoleResponseDto[]>>({
|
||||
url: "/admin/roles",
|
||||
client: "admin",
|
||||
method: "get",
|
||||
});
|
||||
};
|
||||
|
||||
const create = (data: CreateRoleDto) => {
|
||||
return apiRequest<ApiResponse<RoleResponseDto>>({
|
||||
url: "/admin/roles",
|
||||
client: "admin",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const update = (roleId: string, data: UpdateRoleDto) => {
|
||||
return apiRequest<ApiResponse<RoleResponseDto>>({
|
||||
url: `/admin/roles/${roleId}`,
|
||||
client: "admin",
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (roleId: string) => {
|
||||
return apiRequest<ApiResponse<null>>({
|
||||
url: `/admin/roles/${roleId}`,
|
||||
client: "admin",
|
||||
method: "delete",
|
||||
});
|
||||
};
|
||||
|
||||
const assignPermission = (roleId: string, permissionId: string) => {
|
||||
return apiRequest<ApiResponse<RolePermissionResponseDto>>({
|
||||
url: `/admin/roles/${roleId}/permissions/${permissionId}`,
|
||||
client: "admin",
|
||||
method: "post",
|
||||
});
|
||||
};
|
||||
|
||||
const removePermission = (roleId: string, permissionId: string) => {
|
||||
return apiRequest<ApiResponse<null>>({
|
||||
url: `/admin/roles/${roleId}/permissions/${permissionId}`,
|
||||
client: "admin",
|
||||
method: "delete",
|
||||
});
|
||||
};
|
||||
|
||||
export const adminRolesService = {
|
||||
getAll,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
assignPermission,
|
||||
removePermission,
|
||||
};
|
||||
38
src/lib/api/example/admin/roles/types.ts
Normal file
38
src/lib/api/example/admin/roles/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Role DTOs - Matches Backend
|
||||
export interface RoleResponseDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: PermissionInfo[];
|
||||
_count?: {
|
||||
users: number;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
// Create/Update Role
|
||||
export interface CreateRoleDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Role Permission Assignment
|
||||
export interface RolePermissionResponseDto {
|
||||
id: string;
|
||||
roleId: string;
|
||||
permissionId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
112
src/lib/api/example/admin/roles/use-hooks.ts
Normal file
112
src/lib/api/example/admin/roles/use-hooks.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiResponse } from "@/types/api-response";
|
||||
import { adminRolesService } from "./service";
|
||||
import {
|
||||
RoleResponseDto,
|
||||
CreateRoleDto,
|
||||
UpdateRoleDto,
|
||||
RolePermissionResponseDto,
|
||||
} from "./types";
|
||||
|
||||
export const AdminRolesQueryKeys = {
|
||||
all: ["admin-roles"] as const,
|
||||
list: () => [...AdminRolesQueryKeys.all, "list"] as const,
|
||||
};
|
||||
|
||||
export function useGetAllRoles() {
|
||||
const queryKey = AdminRolesQueryKeys.list();
|
||||
|
||||
const { data, ...rest } = useQuery<ApiResponse<RoleResponseDto[]>>({
|
||||
queryKey: queryKey,
|
||||
queryFn: adminRolesService.getAll,
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useCreateRole() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<RoleResponseDto>,
|
||||
Error,
|
||||
CreateRoleDto
|
||||
>({
|
||||
mutationFn: (roleData) => adminRolesService.create(roleData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useUpdateRole() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<RoleResponseDto>,
|
||||
Error,
|
||||
{ roleId: string; data: UpdateRoleDto }
|
||||
>({
|
||||
mutationFn: ({ roleId, data }) => adminRolesService.update(roleId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useDeleteRole() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<null>,
|
||||
Error,
|
||||
{ roleId: string }
|
||||
>({
|
||||
mutationFn: ({ roleId }) => adminRolesService.remove(roleId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useAssignPermission() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<RolePermissionResponseDto>,
|
||||
Error,
|
||||
{ roleId: string; permissionId: string }
|
||||
>({
|
||||
mutationFn: ({ roleId, permissionId }) =>
|
||||
adminRolesService.assignPermission(roleId, permissionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
|
||||
export function useRemovePermission() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, ...rest } = useMutation<
|
||||
ApiResponse<null>,
|
||||
Error,
|
||||
{ roleId: string; permissionId: string }
|
||||
>({
|
||||
mutationFn: ({ roleId, permissionId }) =>
|
||||
adminRolesService.removePermission(roleId, permissionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return { data: data?.data, ...rest };
|
||||
}
|
||||
53
src/lib/api/example/admin/users/service.ts
Normal file
53
src/lib/api/example/admin/users/service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { apiRequest } from "@/lib/api/api-service";
|
||||
import { ApiResponse } from "@/types/api-response";
|
||||
import { UserResponseDto } from "@/types/user";
|
||||
import {
|
||||
UsersQueryParams,
|
||||
PaginatedUsersResponse,
|
||||
UserRoleResponseDto,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Admin Users Service - Example Implementation
|
||||
* Matches Backend: /api/admin/users/*
|
||||
*/
|
||||
|
||||
const getAll = (params?: UsersQueryParams) => {
|
||||
return apiRequest<ApiResponse<PaginatedUsersResponse>>({
|
||||
url: "/admin/users",
|
||||
client: "admin",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleActive = (userId: string) => {
|
||||
return apiRequest<ApiResponse<UserResponseDto>>({
|
||||
url: `/admin/users/${userId}/toggle-active`,
|
||||
client: "admin",
|
||||
method: "put",
|
||||
});
|
||||
};
|
||||
|
||||
const assignRole = (userId: string, roleId: string) => {
|
||||
return apiRequest<ApiResponse<UserRoleResponseDto>>({
|
||||
url: `/admin/users/${userId}/roles/${roleId}`,
|
||||
client: "admin",
|
||||
method: "post",
|
||||
});
|
||||
};
|
||||
|
||||
const removeRole = (userId: string, roleId: string) => {
|
||||
return apiRequest<ApiResponse<null>>({
|
||||
url: `/admin/users/${userId}/roles/${roleId}`,
|
||||
client: "admin",
|
||||
method: "delete",
|
||||
});
|
||||
};
|
||||
|
||||
export const adminUsersService = {
|
||||
getAll,
|
||||
toggleActive,
|
||||
assignRole,
|
||||
removeRole,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user