style: added shadcn-ui
This commit is contained in:
41
components/custom/card.tsx
Normal file
41
components/custom/card.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '../../components/ui/card';
|
||||
import Footer from './footer';
|
||||
|
||||
type CustomCardProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
content: ReactNode;
|
||||
style?: string;
|
||||
footer?: boolean;
|
||||
};
|
||||
|
||||
export const CustomCard = ({
|
||||
title,
|
||||
description,
|
||||
content,
|
||||
style,
|
||||
footer = true,
|
||||
}: CustomCardProps) => {
|
||||
return (
|
||||
<Card className={style ?? 'w-full sm:w-2/3 md:w-2/5 lg:w-1/3 xl:w-1/4'}>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{content}</CardContent>
|
||||
{footer && (
|
||||
<CardFooter className="flex justify-center space-x-4">
|
||||
<Footer />
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
5
components/custom/error.tsx
Normal file
5
components/custom/error.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
export default function ErrorMessage() {
|
||||
return 'Oops. Something went wrong. Please try later :(';
|
||||
}
|
||||
28
components/custom/footer.tsx
Normal file
28
components/custom/footer.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Link } from './link';
|
||||
|
||||
const links = [
|
||||
{ name: 'Subscribe', path: '/' },
|
||||
{ name: 'Privacy Policy', path: '/privacy' },
|
||||
];
|
||||
|
||||
function Footer() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<ul className="flex justify-center space-x-4">
|
||||
{links.map(
|
||||
(link) =>
|
||||
pathname !== link.path &&
|
||||
!(pathname === '/confirmation' && link.path === '/subscribe') && (
|
||||
<Link key={link.path} path={link.path} text={link.name} />
|
||||
)
|
||||
)}
|
||||
{pathname === '/privacy' && (
|
||||
<Link path="/unsubscribe" text="Unsubscribe" />
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
15
components/custom/link.tsx
Normal file
15
components/custom/link.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import NextLink from 'next/link';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type LinkProps = {
|
||||
path: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function Link({ path, text }: LinkProps) {
|
||||
return (
|
||||
<Button asChild>
|
||||
<NextLink href={path}>{text}</NextLink>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
type CustomLinkProps = {
|
||||
path: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function CustomLink({ path, text }: CustomLinkProps) {
|
||||
return (
|
||||
<Link href={path} className="overflow-hidden rounded-md">
|
||||
<h1>{text}</h1>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { HomeLink } from './homeLink';
|
||||
|
||||
export default function ErrorComponent() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Oops. Something went wrong. Please try later :(</h1>
|
||||
<HomeLink />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CustomLink } from './customLink';
|
||||
|
||||
export function HomeLink() {
|
||||
return <CustomLink path={`/`} text={`Home`} />;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { HomeLink } from './homeLink';
|
||||
|
||||
export function SuccessComponent(message: string) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Success!</h1>
|
||||
<h3>{message}</h3>
|
||||
<HomeLink />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,65 @@ import { Container } from '@react-email/container';
|
||||
import { Html } from '@react-email/html';
|
||||
import { Section } from '@react-email/section';
|
||||
import { Text } from '@react-email/text';
|
||||
import { container, main, paragraph } from './utils/styling';
|
||||
import { z } from 'zod';
|
||||
import { NewsSchema } from '../../utils/types';
|
||||
|
||||
export default function NewsletterEmail(ids: number[]) {
|
||||
return (
|
||||
<Html>
|
||||
<Section style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={paragraph}>
|
||||
These were the ids retrieved: {ids.join(', ')}
|
||||
</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
</Html>
|
||||
);
|
||||
export default function NewsletterTemplate(
|
||||
stories: z.infer<typeof NewsSchema>[]
|
||||
) {
|
||||
return {
|
||||
subject: `What's new from Hackernews?`,
|
||||
template: (
|
||||
<Html>
|
||||
<Section style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={paragraph}>
|
||||
{stories.map(story => {
|
||||
return (
|
||||
<div
|
||||
key={story.id}
|
||||
style={{
|
||||
padding: '10px',
|
||||
border: '1px solid #ccc',
|
||||
boxShadow: '2px 2px 5px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
>
|
||||
<h1>{story.title}</h1>
|
||||
<p>{story.by}</p>
|
||||
{story.text && (
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
story.text.length > 500
|
||||
? story.text.substring(0, 500) + '...'
|
||||
: story.text
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{story.url && <a href={story.url}>Read more</a>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
</Html>
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#ffffff'
|
||||
};
|
||||
|
||||
const container = {
|
||||
margin: '0 auto',
|
||||
padding: '20px 0 48px',
|
||||
width: '580px'
|
||||
};
|
||||
|
||||
const paragraph = {
|
||||
fontSize: '18px',
|
||||
lineHeight: '1.4',
|
||||
color: '#484848'
|
||||
};
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { Container } from '@react-email/container';
|
||||
import { Html } from '@react-email/html';
|
||||
import { Section } from '@react-email/section';
|
||||
import { Text } from '@react-email/text';
|
||||
import { container, main, paragraph } from './utils/styling';
|
||||
import Email from './template';
|
||||
|
||||
export default function SubscribeEmail(code: string) {
|
||||
return (
|
||||
<Html>
|
||||
<Section style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={paragraph}>
|
||||
To confirm the subscription, please click{' '}
|
||||
export default function ConfirmationEmail(code: string) {
|
||||
return {
|
||||
subject: 'Welcome!',
|
||||
template: (
|
||||
<Email
|
||||
title={'Welcome!'}
|
||||
body={
|
||||
<>
|
||||
Thank you for subscribing. Please confirm your email address by
|
||||
clicking{' '}
|
||||
<a href={`${process.env.HOME_URL}/confirmation?code=${code}`}>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
</Html>
|
||||
);
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
43
components/emails/template.tsx
Normal file
43
components/emails/template.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Container } from '@react-email/container';
|
||||
import { Html } from '@react-email/html';
|
||||
import { Section } from '@react-email/section';
|
||||
import { Text } from '@react-email/text';
|
||||
|
||||
type EmailProps = {
|
||||
title: string;
|
||||
body: JSX.Element;
|
||||
};
|
||||
|
||||
export default function Email({ title, body }: EmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Section style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={heading}>{title}</Text>
|
||||
<Text style={paragraph}>{body}</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#ffffff',
|
||||
};
|
||||
|
||||
const container = {
|
||||
margin: '0 auto',
|
||||
padding: '20px 0 48px',
|
||||
width: '580px',
|
||||
};
|
||||
|
||||
const heading = {
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
};
|
||||
|
||||
const paragraph = {
|
||||
fontSize: '16px',
|
||||
marginBottom: '16px',
|
||||
};
|
||||
@@ -1,19 +1,13 @@
|
||||
import { Container } from '@react-email/container';
|
||||
import { Html } from '@react-email/html';
|
||||
import { Section } from '@react-email/section';
|
||||
import { Text } from '@react-email/text';
|
||||
import { container, main, paragraph } from './utils/styling';
|
||||
import Email from './template';
|
||||
|
||||
export default function UnsubscribeEmail() {
|
||||
return (
|
||||
<Html>
|
||||
<Section style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={paragraph}>
|
||||
You have unsubscribed from the newsletter.
|
||||
</Text>
|
||||
</Container>
|
||||
</Section>
|
||||
</Html>
|
||||
);
|
||||
export default function UnsubscribeTemplate() {
|
||||
return {
|
||||
subject: 'Unsubscribe confirmation',
|
||||
template: (
|
||||
<Email
|
||||
title="We're sad you're leaving :("
|
||||
body={<>You have unsubscribed from the newsletter.</>}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export const main = {
|
||||
backgroundColor: '#ffffff'
|
||||
};
|
||||
|
||||
export const container = {
|
||||
margin: '0 auto',
|
||||
padding: '20px 0 48px',
|
||||
width: '580px'
|
||||
};
|
||||
|
||||
export const paragraph = {
|
||||
fontSize: '18px',
|
||||
lineHeight: '1.4',
|
||||
color: '#484848'
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
'use client';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { ResponseSchema } from '../../utils/types';
|
||||
import { HomeLink } from '../elements/homeLink';
|
||||
|
||||
export const ConfirmationPage = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const code = searchParams.get('code');
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
fetch('/api/confirmation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: code,
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
router.push('/');
|
||||
}
|
||||
const response: z.infer<typeof ResponseSchema> = await res.json();
|
||||
return response;
|
||||
})
|
||||
.then((response) => {
|
||||
setMessage(response.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [code, router]);
|
||||
|
||||
if (!loading) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{message}</h1>
|
||||
<HomeLink />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Verifying...</h1>
|
||||
<HomeLink />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { ResponseSchema } from '../../utils/types';
|
||||
import { CustomLink } from '../elements/customLink';
|
||||
import ErrorComponent from '../elements/error';
|
||||
import { HomeLink } from '../elements/homeLink';
|
||||
import { SuccessComponent } from '../elements/success';
|
||||
|
||||
export const SubscribeForm = () => {
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const honeypotRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (honeypotRef.current) {
|
||||
honeypotRef.current.style.display = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
|
||||
if (data.get('name')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: data.get('email'),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Invalid response: ${response.status}`);
|
||||
}
|
||||
|
||||
const formResponse: z.infer<typeof ResponseSchema> =
|
||||
await response.json();
|
||||
|
||||
setMessage(formResponse.message);
|
||||
setCompleted(true);
|
||||
} catch (error) {
|
||||
console.log('Subscribe error', error);
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return ErrorComponent();
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
return SuccessComponent(message);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="container" onSubmit={handleSubmit}>
|
||||
<h1>Subscribe to newsletter</h1>
|
||||
<div className="email block">
|
||||
<label htmlFor="frm-email">Email</label>
|
||||
<input
|
||||
placeholder="example@email.com"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<input type="text" name="name" ref={honeypotRef} />
|
||||
<div className="button block">
|
||||
<button type="submit">Subscribe</button>
|
||||
</div>
|
||||
</form>
|
||||
<HomeLink />
|
||||
<CustomLink path={`/unsubscribe`} text="Unsubscribe" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { ResponseSchema } from '../../utils/types';
|
||||
import { CustomLink } from '../elements/customLink';
|
||||
import ErrorComponent from '../elements/error';
|
||||
import { HomeLink } from '../elements/homeLink';
|
||||
import { SuccessComponent } from '../elements/success';
|
||||
|
||||
export const UnsubscribeForm = () => {
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const honeypotRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (honeypotRef.current) {
|
||||
honeypotRef.current.style.display = 'none';
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
|
||||
if (data.get('name')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: data.get('email'),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Invalid response: ${response.status}`);
|
||||
}
|
||||
|
||||
const formResponse: z.infer<typeof ResponseSchema> =
|
||||
await response.json();
|
||||
|
||||
setMessage(formResponse.message);
|
||||
setCompleted(true);
|
||||
} catch (error) {
|
||||
console.log('Unsubscribe error', error);
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return ErrorComponent();
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
return SuccessComponent(message);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="container" onSubmit={handleSubmit}>
|
||||
<h1>Unsubscribe from newsletter</h1>
|
||||
<div className="email block">
|
||||
<label htmlFor="frm-email">Email</label>
|
||||
<input
|
||||
placeholder="example@email.com"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<input type="text" name="name" ref={honeypotRef} />
|
||||
<div className="button block">
|
||||
<button type="submit">Unsubscribe</button>
|
||||
</div>
|
||||
</form>
|
||||
<HomeLink />
|
||||
<CustomLink path={`/subscribe`} text="Subscribe" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
components/ui/button.tsx
Normal file
54
components/ui/button.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../utils/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonProps = {
|
||||
asChild?: boolean;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
85
components/ui/card.tsx
Normal file
85
components/ui/card.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../utils/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-card text-card-foreground rounded-lg border shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
};
|
||||
176
components/ui/form.tsx
Normal file
176
components/ui/form.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext
|
||||
} from 'react-hook-form';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { cn } from '../../utils/utils';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm font-medium', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField
|
||||
};
|
||||
22
components/ui/input.tsx
Normal file
22
components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../utils/utils';
|
||||
|
||||
const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
25
components/ui/label.tsx
Normal file
25
components/ui/label.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../utils/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
Reference in New Issue
Block a user