Merge pull request #1 from RiccardoSenica/theme-and-ui

style: added shadcn-ui
This commit is contained in:
Riccardo Senica
2023-12-05 20:23:55 +01:00
committed by GitHub
45 changed files with 1463 additions and 1340 deletions

View File

@@ -1,3 +1,4 @@
# Created by Vercel CLI
NX_DAEMON="" NX_DAEMON=""
POSTGRES_DATABASE="" POSTGRES_DATABASE=""
POSTGRES_HOST="" POSTGRES_HOST=""
@@ -27,3 +28,4 @@ RESEND_KEY=""
RESEND_FROM="" RESEND_FROM=""
SECRET_HASH="" SECRET_HASH=""
HOME_URL="" HOME_URL=""
MAINTENANCE_MODE=0

View File

@@ -2,8 +2,7 @@
## To do ## To do
- A proper UI - Polish the UI
- Email templates
- Captcha? - Captcha?
- Tests - Tests
@@ -38,3 +37,9 @@ Generate Prisma client
```bash ```bash
yarn prisma:generate yarn prisma:generate
``` ```
Reset Prisma database
```bash
yarn db:reset
```

View File

@@ -28,7 +28,7 @@ export async function POST(request: Request) {
}); });
const message: z.infer<typeof ResponseSchema> = { const message: z.infer<typeof ResponseSchema> = {
message: `Thank you for confirming the subscripion!` message: `Thank you for confirming the subscription, ${user.email}!`
}; };
return ApiResponse(200, JSON.stringify(message)); return ApiResponse(200, JSON.stringify(message));

View File

@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import NewsletterEmail from '../../../components/emails/newsletter'; import NewsletterTemplate from '../../../components/emails/newsletter';
import prisma from '../../../prisma/prisma'; import prisma from '../../../prisma/prisma';
import { sendEmail } from '../../../utils/sender'; import { sendEmail } from '../../../utils/sender';
import { NewsSchema } from '../../../utils/types'; import { NewsDatabaseSchema, NewsSchema } from '../../../utils/types';
import { singleNews, topNews } from '../../../utils/urls'; import { singleNews, topNews } from '../../../utils/urls';
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -18,28 +18,28 @@ export async function GET(request: Request) {
const newsPromises = topstories const newsPromises = topstories
.splice(0, Number(process.env.NEWS_LIMIT)) .splice(0, Number(process.env.NEWS_LIMIT))
.map(async id => { .map(async id => {
const sourceNews: z.infer<typeof NewsSchema> = await fetch( const sourceNews = await fetch(singleNews(id)).then(res => res.json());
singleNews(id) const validation = NewsDatabaseSchema.safeParse(sourceNews);
).then(res => res.json());
return await prisma.news.upsert({ if (validation.success) {
const result = await prisma.news.upsert({
create: { create: {
...sourceNews, ...validation.data,
id id
}, },
update: { update: {
...sourceNews ...validation.data
}, },
where: { where: {
id id
},
select: {
id: true
} }
}); });
return result;
}
}); });
const newsIds = await Promise.all(newsPromises); const news = await Promise.all(newsPromises);
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { where: {
@@ -58,10 +58,13 @@ export async function GET(request: Request) {
}); });
} }
const validRankedNews = news
.filter((item): item is z.infer<typeof NewsSchema> => item !== undefined)
.sort((a, b) => b.score - a.score);
await sendEmail( await sendEmail(
users.map(user => user.email), users.map(user => user.email),
`What's new from Hackernews?`, NewsletterTemplate(validRankedNews)
NewsletterEmail(newsIds.map(news => news.id))
); );
return new NextResponse(`Newsletter sent to ${users.length} addresses.`, { return new NextResponse(`Newsletter sent to ${users.length} addresses.`, {

View File

@@ -1,6 +1,6 @@
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { z } from 'zod'; import { z } from 'zod';
import SubscribeEmail from '../../../components/emails/subscribe'; import SubscribeTemplate from '../../../components/emails/subscribe';
import prisma from '../../../prisma/prisma'; import prisma from '../../../prisma/prisma';
import { ApiResponse } from '../../../utils/apiResponse'; import { ApiResponse } from '../../../utils/apiResponse';
import { sendEmail } from '../../../utils/sender'; import { sendEmail } from '../../../utils/sender';
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
} }
}); });
await sendEmail([email], 'Welcome!', SubscribeEmail(code)); await sendEmail([email], SubscribeTemplate(code));
const message: z.infer<typeof ResponseSchema> = { const message: z.infer<typeof ResponseSchema> = {
message: `Thank you! You will now receive an email to ${email} to confirm the subscription.` message: `Thank you! You will now receive an email to ${email} to confirm the subscription.`

View File

@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import UnsubscribeEmail from '../../../components/emails/unsubscribe'; import UnsubscribeTemplate from '../../../components/emails/unsubscribe';
import prisma from '../../../prisma/prisma'; import prisma from '../../../prisma/prisma';
import { ApiResponse } from '../../../utils/apiResponse'; import { ApiResponse } from '../../../utils/apiResponse';
import { sendEmail } from '../../../utils/sender'; import { sendEmail } from '../../../utils/sender';
@@ -31,11 +31,11 @@ export async function POST(request: Request) {
} }
}); });
sendEmail([email], 'Unsubscribe confirmation', UnsubscribeEmail()); await sendEmail([email], UnsubscribeTemplate());
} }
const message: z.infer<typeof ResponseSchema> = { const message: z.infer<typeof ResponseSchema> = {
message: `${email} unsubscribed!` message: `${email} unsubscribed.`
}; };
return ApiResponse(200, JSON.stringify(message)); return ApiResponse(200, JSON.stringify(message));

View File

@@ -1,5 +1,59 @@
import { ConfirmationPage } from '../../components/pages/confirmation'; 'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { CustomCard } from '../../components/custom/card';
import { ResponseSchema } from '../../utils/types';
export default function Confirmation() { export default function Confirmation() {
return <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]);
function render() {
if (!loading) {
return message;
}
return 'Just a second...';
}
return (
<CustomCard
style='text-center'
title={loading ? 'Verifying' : 'Confirmed!'}
content={render()}
footer={false}
/>
);
} }

View File

@@ -1,92 +1,77 @@
html, @tailwind base;
body { @tailwind components;
padding: 0; @tailwind utilities;
margin: 0;
font-family: @layer base {
-apple-system, :root {
BlinkMacSystemFont, --background: 0 0% 100%;
Segoe UI, --foreground: 0 0% 3.9%;
Roboto,
Oxygen, --card: 0 0% 100%;
Ubuntu, --card-foreground: 0 0% 3.9%;
Cantarell,
Fira Sans, --popover: 0 0% 100%;
Droid Sans, --popover-foreground: 0 0% 3.9%;
Helvetica Neue,
sans-serif; --primary: 0 0% 9%;
background: #1e1e1e; --primary-foreground: 0 0% 98%;
min-height: 100vh;
display: flex; --secondary: 0 0% 96.1%;
color: rgb(243, 241, 239); --secondary-foreground: 0 0% 9%;
justify-content: center;
align-items: center; --muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
} }
.block { @layer base {
display: flex; * {
flex-direction: column; @apply border-border;
} }
body {
.name { @apply bg-background text-foreground;
display: flex; @apply bg-custom-background bg-size-cover;
flex-direction: row; }
justify-content: space-between;
}
.container {
font-size: 1.3rem;
border-radius: 10px;
width: 85%;
padding: 50px;
box-shadow:
0 54px 55px rgb(78 78 78 / 25%),
0 -12px 30px rgb(78 78 78 / 25%),
0 4px 6px rgb(78 78 78 / 25%),
0 12px 13px rgb(78 78 78 / 25%),
0 -3px 5px rgb(78 78 78 / 25%);
}
.container input {
font-size: 1.2rem;
margin: 10px 0 10px 0px;
border-color: rgb(31, 28, 28);
padding: 10px;
border-radius: 5px;
background-color: #e8f0fe;
}
.container textarea {
margin: 10px 0 10px 0px;
padding: 5px;
border-color: rgb(31, 28, 28);
border-radius: 5px;
background-color: #e8f0fe;
font-size: 20px;
}
.container h1 {
text-align: center;
font-weight: 600;
}
.name div {
display: flex;
flex-direction: column;
}
.block button {
padding: 10px;
font-size: 20px;
width: 30%;
border: 3px solid black;
border-radius: 5px;
}
.button {
display: flex;
align-items: center;
}
textarea {
resize: none;
} }

View File

@@ -1,24 +1,34 @@
import { Analytics } from '@vercel/analytics/react'; import { Analytics } from '@vercel/analytics/react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Inter } from 'next/font/google'; import { Inter as FontSans } from 'next/font/google';
import { cn } from '../utils/utils';
import './globals.css'; import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Hacker News newsletter', title: 'Hacker News newsletter',
description: 'Newsletter delivering the best posts from Hacker News', description: 'Newsletter delivering the best posts from Hacker News',
keywords: 'newsletter, hackernews, technology, coding, programming, news' keywords: 'newsletter, hackernews, technology, coding, programming, news'
}; };
export const fontSans = FontSans({
subsets: ['latin'],
variable: '--font-sans'
});
export default function RootLayout({ export default function RootLayout({
children children
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang='en'> <html lang='en' suppressHydrationWarning>
<body className={inter.className}> <head />
<body
className={cn(
'flex min-h-screen items-center justify-center bg-background font-sans antialiased',
fontSans.variable
)}
>
{children} {children}
<Analytics /> <Analytics />
</body> </body>

View File

@@ -1,11 +1,111 @@
import { CustomLink } from '../components/elements/customLink'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { CustomCard } from '../components/custom/card';
import ErrorMessage from '../components/custom/error';
import { Button } from '../components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage
} from '../components/ui/form';
import { Input } from '../components/ui/input';
import { ResponseSchema, SubscribeFormSchema } from '../utils/types';
export default function Home() { export default function Home() {
const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null);
const form = useForm<z.infer<typeof SubscribeFormSchema>>({
resolver: zodResolver(SubscribeFormSchema),
defaultValues: {
email: '',
name: ''
}
});
useEffect(() => {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
}
}, []);
async function handleSubmit(values: z.infer<typeof SubscribeFormSchema>) {
if (values.name) {
return;
}
try {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: values.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);
}
}
function render() {
if (error) {
return ErrorMessage();
}
if (completed) {
return message;
}
return ( return (
<div> <Form {...form}>
<CustomLink path='/subscribe' text='Subscribe' /> <form
<CustomLink path='/unsubscribe' text='Unsubscribe' /> onSubmit={form.handleSubmit(handleSubmit)}
<CustomLink path='/privacy' text='Privacy' /> className='flex items-center justify-center space-x-4'
</div> >
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder='example@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit'>Submit</Button>
</form>
</Form>
);
}
return (
<CustomCard
style='text-center'
title='Hackernews + newsletter'
description='Top stories from Hackernews. Once a day. Every day.'
content={render()}
/>
); );
} }

View File

@@ -1,8 +1,9 @@
'use client';
import { CustomCard } from '../../components/custom/card';
export default function Privacy() { export default function Privacy() {
return ( const body = (
<div> <div>
<h1>Privacy Policy</h1>
<p>Last updated: December 03, 2023</p>
<p> <p>
This Privacy Policy describes Our policies and procedures on the This Privacy Policy describes Our policies and procedures on the
collection, use and disclosure of Your information when You use the collection, use and disclosure of Your information when You use the
@@ -411,4 +412,13 @@ export default function Privacy() {
</ul> </ul>
</div> </div>
); );
return (
<CustomCard
title='Privacy Policy'
description='Last updated: December 03, 2023'
content={body}
style='w-2/3'
/>
);
} }

View File

@@ -9,16 +9,16 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 1 priority: 1
}, },
{ {
url: `${process.env.HOME_URL!}/subscribe`, url: `${process.env.HOME_URL!}/privacy`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: 'yearly',
priority: 0.8 priority: 0.5
}, },
{ {
url: `${process.env.HOME_URL!}/unsubscribe`, url: `${process.env.HOME_URL!}/unsubscribe`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: 'yearly',
priority: 0.5 priority: 0.2
} }
]; ];
} }

View File

@@ -1,5 +0,0 @@
import { SubscribeForm } from '../../components/pages/subscribe';
export default function Subscribe() {
return <SubscribeForm />;
}

View File

@@ -1,5 +1,111 @@
import { UnsubscribeForm } from '../../components/pages/unsubscribe'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { CustomCard } from '../../components/custom/card';
import ErrorMessage from '../../components/custom/error';
import { Button } from '../../components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage
} from '../../components/ui/form';
import { Input } from '../../components/ui/input';
import { ResponseSchema, UnsubscribeFormSchema } from '../../utils/types';
export default function Unsubscribe() { export default function Unsubscribe() {
return <UnsubscribeForm />; const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null);
const form = useForm<z.infer<typeof UnsubscribeFormSchema>>({
resolver: zodResolver(UnsubscribeFormSchema),
defaultValues: {
email: '',
name: ''
}
});
useEffect(() => {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
}
}, []);
async function handleSubmit(values: z.infer<typeof UnsubscribeFormSchema>) {
if (values.name) {
return;
}
try {
const response = await fetch('/api/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: values.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);
}
}
function render() {
if (error) {
return ErrorMessage();
}
if (completed) {
return message;
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='flex items-center justify-center space-x-4'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder='example@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit'>Submit</Button>
</form>
</Form>
);
}
return (
<CustomCard
style='text-center'
title='Unsubscribe'
description='You sure you want to leave? :('
content={render()}
/>
);
} }

View 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>
);
};

View File

@@ -0,0 +1,5 @@
'use client';
export default function ErrorMessage() {
return 'Oops. Something went wrong. Please try later :(';
}

View 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;

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import { CustomLink } from './customLink';
export function HomeLink() {
return <CustomLink path={`/`} text={`Home`} />;
}

View File

@@ -1,11 +0,0 @@
import { HomeLink } from './homeLink';
export function SuccessComponent(message: string) {
return (
<div>
<h1>Success!</h1>
<h3>{message}</h3>
<HomeLink />
</div>
);
}

View File

@@ -2,18 +2,65 @@ import { Container } from '@react-email/container';
import { Html } from '@react-email/html'; import { Html } from '@react-email/html';
import { Section } from '@react-email/section'; import { Section } from '@react-email/section';
import { Text } from '@react-email/text'; 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[]) { export default function NewsletterTemplate(
return ( stories: z.infer<typeof NewsSchema>[]
) {
return {
subject: `What's new from Hackernews?`,
template: (
<Html> <Html>
<Section style={main}> <Section style={main}>
<Container style={container}> <Container style={container}>
<Text style={paragraph}> <Text style={paragraph}>
These were the ids retrieved: {ids.join(', ')} {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> </Text>
</Container> </Container>
</Section> </Section>
</Html> </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'
};

View File

@@ -1,23 +1,22 @@
import { Container } from '@react-email/container'; import Email from './template';
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';
export default function SubscribeEmail(code: string) { export default function ConfirmationEmail(code: string) {
return ( return {
<Html> subject: 'Welcome!',
<Section style={main}> template: (
<Container style={container}> <Email
<Text style={paragraph}> title={'Welcome!'}
To confirm the subscription, please click{' '} body={
<>
Thank you for subscribing. Please confirm your email address by
clicking{' '}
<a href={`${process.env.HOME_URL}/confirmation?code=${code}`}> <a href={`${process.env.HOME_URL}/confirmation?code=${code}`}>
here here
</a> </a>
. .
</Text> </>
</Container> }
</Section> />
</Html> )
); };
} }

View 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',
};

View File

@@ -1,19 +1,13 @@
import { Container } from '@react-email/container'; import Email from './template';
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';
export default function UnsubscribeEmail() { export default function UnsubscribeTemplate() {
return ( return {
<Html> subject: 'Unsubscribe confirmation',
<Section style={main}> template: (
<Container style={container}> <Email
<Text style={paragraph}> title="We're sad you're leaving :("
You have unsubscribed from the newsletter. body={<>You have unsubscribed from the newsletter.</>}
</Text> />
</Container> ),
</Section> };
</Html>
);
} }

View File

@@ -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'
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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
View 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
View 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
View 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
View 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
View 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 };

View File

@@ -1,6 +1,17 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true reactStrictMode: true,
redirects() {
return [
process.env.MAINTENANCE_MODE === '1'
? {
source: '/((?!maintenance).*)',
destination: '/maintenance.html',
permanent: false
}
: null
].filter(Boolean);
}
}; };
module.exports = nextConfig; module.exports = nextConfig;

View File

@@ -14,7 +14,8 @@
"vercel:link": "vercel link", "vercel:link": "vercel link",
"vercel:env": "vercel env pull .env", "vercel:env": "vercel env pull .env",
"prisma:push": "npx prisma db push", "prisma:push": "npx prisma db push",
"prisma:generate": "npx prisma generate" "prisma:generate": "npx prisma generate",
"prisma:reset": "npx prisma db push --force-reset"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.6.0", "@prisma/client": "^5.6.0",
@@ -24,14 +25,19 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"zod": "^3.22.4", "zod": "^3.22.4",
"zod-validation-error": "^2.1.0",
"@react-email/container": "^0.0.10", "@react-email/container": "^0.0.10",
"@react-email/html": "^0.0.6", "@react-email/html": "^0.0.6",
"@react-email/section": "^0.0.10", "@react-email/section": "^0.0.10",
"@react-email/text": "^0.0.6", "@react-email/text": "^0.0.6",
"crypto": "^1.0.1", "resend": "^2.0.0",
"react-email": "^1.9.5", "@hookform/resolvers": "^3.3.2",
"resend": "^2.0.0" "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"react-hook-form": "^7.48.2",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^18.4.3", "@commitlint/cli": "^18.4.3",

BIN
public/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

45
public/maintenance.html Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Maintenance</title>
<style>
body {
background: url('/background.jpg') no-repeat center center fixed;
-webkit-background-size: cover;
-moz-background-size: cover;
-o-background-size: cover;
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.card {
width: 100%;
max-width: 600px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
font-family: Arial, sans-serif;
background-color: rgba(255, 255, 255, 0.8);
}
.card h1 {
font-size: 2em;
color: #333;
}
.card p {
font-size: 1em;
color: #666;
}
</style>
</head>
<body>
<div class="card">
<h1>Maintenance</h1>
<p>We are doing stuff. Please come back later...</p>
</div>
</body>
</html>

View File

@@ -1,20 +1,87 @@
import type { Config } from "tailwindcss"; import { fontFamily } from 'tailwindcss/defaultTheme';
const config: Config = { /** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", './pages/**/*.{ts,tsx}',
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", './components/**/*.{ts,tsx}',
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", './app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}'
], ],
theme: { theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: { extend: {
backgroundImage: { backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 'custom-background': "url('/background.jpg')"
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
}, },
backgroundSize: {
'size-cover': '100% auto'
}, },
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
}, },
plugins: [], secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' }
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
},
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans]
}
}
},
plugins: [require('tailwindcss-animate')]
}; };
export default config;

View File

@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "ES5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,

View File

@@ -1,9 +1,13 @@
import { Resend } from 'resend'; import { Resend } from 'resend';
type EmailTemplate = {
subject: string;
template: JSX.Element;
};
export async function sendEmail( export async function sendEmail(
to: string[], to: string[],
subject: string, { subject, template }: EmailTemplate
template: JSX.Element
) { ) {
const resend = new Resend(process.env.RESEND_KEY); const resend = new Resend(process.env.RESEND_KEY);
@@ -21,4 +25,6 @@ export async function sendEmail(
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
console.log('Email sent', subject, to.length);
} }

View File

@@ -6,6 +6,7 @@ export const ResponseSchema = z.object({
export const SubscribeFormSchema = z.object({ export const SubscribeFormSchema = z.object({
email: z.string().email(), email: z.string().email(),
name: z.string().optional(),
}); });
export const ConfirmationSchema = z.object({ export const ConfirmationSchema = z.object({
@@ -14,9 +15,10 @@ export const ConfirmationSchema = z.object({
export const UnsubscribeFormSchema = z.object({ export const UnsubscribeFormSchema = z.object({
email: z.string().email(), email: z.string().email(),
name: z.string().optional(),
}); });
export const NewsSchema = z.object({ export const NewsDatabaseSchema = z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
text: z.string().optional(), text: z.string().optional(),
@@ -26,3 +28,15 @@ export const NewsSchema = z.object({
url: z.string().optional(), url: z.string().optional(),
score: z.number(), score: z.number(),
}); });
export const NewsSchema = z.object({
id: z.number(),
title: z.string(),
text: z.string().nullable(),
type: z.string(),
by: z.string(),
time: z.number(),
url: z.string().nullable(),
score: z.number(),
createdAt: z.date(),
});

6
utils/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

1127
yarn.lock

File diff suppressed because it is too large Load Diff