refactor: improve news and email handling, style, folder structure (#16)

This commit is contained in:
Riccardo Senica
2024-06-04 18:04:54 +08:00
committed by GitHub
parent bc5e0cc195
commit acc10bf5fd
62 changed files with 1737 additions and 1553 deletions

View File

@@ -1,4 +1,13 @@
# Created by Vercel CLI
ADMIN_EMAIL=""
CRON_SECRET=""
HOME_URL=""
MAINTENANCE_MODE=""
GOOGLE_ANALYTICS=""
NEWS_LIMIT=""
NEXT_PUBLIC_BRAND_COUNTRY=""
NEXT_PUBLIC_BRAND_EMAIL=""
NEXT_PUBLIC_BRAND_NAME=""
NX_DAEMON=""
POSTGRES_DATABASE=""
POSTGRES_HOST=""
@@ -7,6 +16,10 @@ POSTGRES_PRISMA_URL=""
POSTGRES_URL=""
POSTGRES_URL_NON_POOLING=""
POSTGRES_USER=""
RESEND_AUDIENCE=""
RESEND_FROM=""
RESEND_KEY=""
SECRET_HASH=""
TURBO_REMOTE_ONLY=""
TURBO_RUN_SUMMARY=""
VERCEL="1"
@@ -23,12 +36,3 @@ VERCEL_GIT_REPO_ID=""
VERCEL_GIT_REPO_OWNER=""
VERCEL_GIT_REPO_SLUG=""
VERCEL_URL=""
NEWS_LIMIT=""
RESEND_KEY=""
RESEND_FROM=""
SECRET_HASH=""
HOME_URL=""
MAINTENANCE_MODE=0
NEXT_PUBLIC_BRAND_NAME=""
NEXT_PUBLIC_BRAND_EMAIL=""
NEXT_PUBLIC_BRAND_COUNTRY=""

View File

@@ -17,6 +17,8 @@
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/consistent-type-definitions": ["error", "type"]
"@typescript-eslint/consistent-type-definitions": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@
# production
/build
# yarn
/.yarn
# misc
.DS_Store
*.pem

View File

@@ -3,6 +3,6 @@
yarn audit-ci
yarn format
yarn lint
yarn lint-staged
yarn typecheck
yarn build

View File

@@ -1 +1,5 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules

View File

@@ -1,11 +1,8 @@
# Hackernews newsletter
## Next up
## Future improvements
- Adjust card size
- Tweak email templates
- Cron every 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue)
- Move to GCP?
## Some resources used

View File

@@ -1,44 +1,61 @@
import { z } from 'zod';
import prisma from '../../../prisma/prisma';
import { ApiResponse } from '../../../utils/apiResponse';
import { ConfirmationSchema, ResponseSchema } from '../../../utils/schemas';
import prisma from '@prisma/prisma';
import { ApiResponse } from '@utils/apiResponse';
import {
BAD_REQUEST,
INTERNAL_SERVER_ERROR,
STATUS_BAD_REQUEST,
STATUS_INTERNAL_SERVER_ERROR,
STATUS_OK
} from '@utils/statusCodes';
import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas';
import { Resend } from 'resend';
export const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: Request) {
const body = await request.json();
const validation = ConfirmationSchema.safeParse(body);
if (!validation.success || !validation.data.code) {
return ApiResponse(400, 'Bad request');
}
const user = await prisma.user.findUnique({
where: {
code: validation.data.code
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_AUDIENCE is not set');
}
const body = await request.json();
const validation = ConfirmationSchema.safeParse(body);
if (!validation.success || !validation.data.code) {
return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
}
});
if (user) {
await prisma.user.update({
const user = await prisma.user.findUnique({
where: {
code: validation.data.code
},
data: {
confirmed: true
}
});
const message: z.infer<typeof ResponseSchema> = {
success: true,
message: `Thank you for confirming the subscription, ${user.email}!`
};
if (user) {
const resend = new Resend(process.env.RESEND_KEY);
return ApiResponse(200, JSON.stringify(message));
await resend.contacts.update({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: false
});
await prisma.user.update({
where: {
code: validation.data.code
},
data: {
confirmed: true
}
});
const message: ResponseType = {
success: true,
message: `Thank you for confirming the subscription, ${user.email}!`
};
return ApiResponse(STATUS_OK, message);
}
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
const message: z.infer<typeof ResponseSchema> = {
success: false,
message: `It was not possible to confirm the subscription.`
};
return ApiResponse(200, JSON.stringify(message));
}

View File

@@ -1,50 +1,81 @@
import { NextResponse } from 'next/server';
import prisma from '../../../prisma/prisma';
import { NewsDatabaseSchema } from '../../../utils/schemas';
import { singleNews, topNews } from '../../../utils/urls';
import prisma from '@prisma/prisma';
import { ApiResponse } from '@utils/apiResponse';
import {
INTERNAL_SERVER_ERROR,
STATUS_INTERNAL_SERVER_ERROR,
STATUS_OK,
STATUS_UNAUTHORIZED
} from '@utils/statusCodes';
import { singleNews, topNews } from '@utils/urls';
import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas';
import { Resend } from 'resend';
export async function GET(request: Request) {
if (
request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}`
) {
return new Response('Unauthorized', { status: 401 });
return ApiResponse(STATUS_UNAUTHORIZED, 'Unauthorized');
}
try {
const topstories: number[] = await fetch(topNews).then(res => res.json());
const topStories: number[] = await fetch(topNews, {
cache: 'no-store'
}).then(res => res.json());
const newsPromises = topstories
.splice(0, Number(process.env.NEWS_LIMIT))
.map(async id => {
const sourceNews = await fetch(singleNews(id)).then(res => res.json());
const validation = NewsDatabaseSchema.safeParse(sourceNews);
console.info(`Top stories ids: ${topStories}`);
if (validation.success) {
const result = await prisma.news.upsert({
create: {
...validation.data,
id
},
update: {
...validation.data
},
where: {
id
}
});
const newsPromises = topStories
.slice(0, Number(process.env.NEWS_LIMIT))
.map(id => fetch(singleNews(id)).then(res => res.json()));
return result;
}
const news: NewsDatabaseType[] = await Promise.all(newsPromises);
const upsertPromises = news.map(async singleNews => {
const validation = NewsDatabaseSchema.safeParse(singleNews);
if (validation.success) {
console.info(
`Validated news N° ${singleNews.id} - ${singleNews.title}`
);
const result = await prisma.news.upsert({
create: {
...validation.data,
id: singleNews.id
},
update: {
...validation.data
},
where: {
id: singleNews.id
}
});
console.info(`Imported N° ${singleNews.id} - ${singleNews.title}`);
return result;
} else {
console.error(validation.error);
}
});
const result = await Promise.all(upsertPromises);
console.info(`Imported ${result.length} news.`);
if (process.env.ADMIN_EMAIL && process.env.RESEND_FROM) {
const resend = new Resend(process.env.RESEND_KEY);
await resend.emails.send({
from: process.env.RESEND_FROM,
to: [process.env.ADMIN_EMAIL],
subject: 'Newsletter: import cron job',
text: `Found these ids ${topStories.join(', ')} and imported ${result.length} of them.`
});
}
await Promise.all(newsPromises);
return new NextResponse(`Imported ${newsPromises.length} news.`, {
status: 200
});
} catch {
return new NextResponse(`Import failed.`, {
status: 500
});
return ApiResponse(STATUS_OK, `Imported ${newsPromises.length} news.`);
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
}

View File

@@ -1,87 +1,114 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import NewsletterTemplate from '../../../components/emails/newsletter';
import prisma from '../../../prisma/prisma';
import { NewsSchema } from '../../../utils/schemas';
import { sender } from '../../../utils/sender';
import NewsletterTemplate from '@components/email/Newsletter';
import prisma from '@prisma/prisma';
import { ApiResponse } from '@utils/apiResponse';
import { sender } from '@utils/sender';
import {
INTERNAL_SERVER_ERROR,
STATUS_INTERNAL_SERVER_ERROR,
STATUS_OK,
STATUS_UNAUTHORIZED
} from '@utils/statusCodes';
import { Resend } from 'resend';
const ONE_DAY_IN_MS = 1000 * 60 * 60 * 24;
const TEN_MINUTES_IN_MS = 1000 * 10 * 60;
export async function GET(request: Request) {
if (
request.headers.get('Authorization') !== `Bearer ${process.env.CRON_SECRET}`
) {
return new Response('Unauthorized', { status: 401 });
return ApiResponse(STATUS_UNAUTHORIZED, 'Unauthorized');
}
// send newsletter to users who didn't get it in the last 23h 50m, assuming a cron job every 10 minutes
// this is to avoid sending the newsletter to the same users multiple times
// this is not a perfect solution, but it's good enough for now
const users = await prisma.user.findMany({
where: {
confirmed: true,
deleted: false,
OR: [
{
lastMail: {
lt: new Date(Date.now() - 1000 * 60 * 60 * 24 + 1000 * 10 * 60) // 24h - 10m
try {
// send newsletter to users who didn't get it in the last 23h 50m, assuming a cron job every 10 minutes
// this is to avoid sending the newsletter to the same users multiple times
// this is not a perfect solution, but it's good enough for now
const users = await prisma.user.findMany({
where: {
confirmed: true,
deleted: false,
OR: [
{
lastMail: {
lt: new Date(Date.now() - ONE_DAY_IN_MS + TEN_MINUTES_IN_MS) // 24h - 10m
}
},
{
lastMail: null
}
},
{
lastMail: null
]
},
select: {
id: true,
email: true
}
});
console.info(`Found ${users.length} users to mail to.`);
if (users.length === 0) {
return ApiResponse(STATUS_OK, 'No user to mail to.');
}
const news = await prisma.news.findMany({
where: {
createdAt: {
gt: new Date(Date.now() - ONE_DAY_IN_MS)
}
]
},
select: {
id: true,
email: true
}
});
if (users.length === 0) {
return new NextResponse('No users.', {
status: 200
},
orderBy: {
score: 'desc'
},
take: 25
});
}
const news = await prisma.news.findMany({
where: {
createdAt: {
gt: new Date(Date.now() - 1000 * 60 * 60 * 24)
}
},
orderBy: {
score: 'desc'
},
take: 25
});
console.info(`Found ${news.length} news to include in the newsletter.`);
const validRankedNews = news
.filter((item): item is z.infer<typeof NewsSchema> => item !== undefined)
.sort((a, b) => b.score - a.score);
if (process.env.ADMIN_EMAIL && process.env.RESEND_FROM) {
const resend = new Resend(process.env.RESEND_KEY);
const sent = await sender(
users.map(user => user.email),
NewsletterTemplate(validRankedNews)
);
if (!sent) {
return new NextResponse('Internal server error', {
status: 500
});
}
// update users so they don't get the newsletter again
await prisma.user.updateMany({
where: {
id: {
in: users.map(user => user.id)
}
},
data: {
lastMail: new Date()
await resend.emails.send({
from: process.env.RESEND_FROM,
to: [process.env.ADMIN_EMAIL],
subject: 'Newsletter: mailing cron job',
text: `Found ${users.length} users and ${news.length} news to send.`
});
}
});
return new NextResponse(`Newsletter sent to ${users.length} addresses.`, {
status: 200
});
if (news.length === 0) {
return ApiResponse(STATUS_OK, 'No news to include in newsletter.');
}
const validRankedNews = news.sort((a, b) => b.score - a.score);
const sent = await sender(
users.map(user => user.email),
NewsletterTemplate(validRankedNews)
);
if (!sent) {
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
// update users so they don't get the newsletter again
await prisma.user.updateMany({
where: {
id: {
in: users.map(user => user.id)
}
},
data: {
lastMail: new Date()
}
});
return ApiResponse(
STATUS_OK,
`Newsletter sent to ${users.length} addresses.`
);
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
}

View File

@@ -1,22 +1,30 @@
import prisma from '../../../prisma/prisma';
import { ApiResponse } from '../../../utils/apiResponse';
import prisma from '@prisma/prisma';
import { ApiResponse } from '@utils/apiResponse';
import {
INTERNAL_SERVER_ERROR,
STATUS_INTERNAL_SERVER_ERROR,
STATUS_OK
} from '@utils/statusCodes';
export async function GET() {
const news = await prisma.news.findMany({
orderBy: {
createdAt: 'desc'
},
take: 50,
select: {
id: true,
title: true,
by: true
try {
const news = await prisma.news.findMany({
orderBy: {
createdAt: 'desc'
},
take: 50,
select: {
id: true,
title: true,
by: true
}
});
if (news) {
return ApiResponse(STATUS_OK, news);
}
});
if (news) {
return ApiResponse(200, JSON.stringify(news));
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
return ApiResponse(500, 'Internal server error');
}

View File

@@ -1,76 +1,123 @@
import ConfirmationTemplate from '@components/email/Confirmation';
import prisma from '@prisma/prisma';
import { ApiResponse } from '@utils/apiResponse';
import { sender } from '@utils/sender';
import {
BAD_REQUEST,
INTERNAL_SERVER_ERROR,
STATUS_BAD_REQUEST,
STATUS_INTERNAL_SERVER_ERROR,
STATUS_OK
} from '@utils/statusCodes';
import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas';
import * as crypto from 'crypto';
import { z } from 'zod';
import ConfirmationTemplate from '../../../components/emails/confirmation';
import prisma from '../../../prisma/prisma';
import { ApiResponse } from '../../../utils/apiResponse';
import { ResponseSchema, SubscribeFormSchema } from '../../../utils/schemas';
import { sender } from '../../../utils/sender';
import { Resend } from 'resend';
export const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: Request) {
const body = await request.json();
const validation = SubscribeFormSchema.safeParse(body);
if (!validation.success) {
return ApiResponse(400, 'Bad request');
}
const { email } = validation.data;
const userAlreadyConfirmed = await prisma.user.findUnique({
where: {
email,
confirmed: true
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_KEY is not set');
}
});
if (userAlreadyConfirmed) {
if (userAlreadyConfirmed.deleted) {
const body = await request.json();
const validation = SubscribeFormSchema.safeParse(body);
if (!validation.success) {
return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
}
const { email } = validation.data;
const user = await prisma.user.findUnique({
where: {
email
}
});
const resend = new Resend(process.env.RESEND_KEY);
const code = crypto
.createHash('sha256')
.update(`${process.env.SECRET_HASH}${email}}`)
.digest('hex');
if (user && user.confirmed) {
if (user.deleted) {
await prisma.user.update({
where: {
email
},
data: {
deleted: false
}
});
const contact = await resend.contacts.get({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE
});
if (!contact) {
await resend.contacts.update({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: true
});
}
}
const message: ResponseType = {
success: true,
message: `Thank you for subscribing!`
};
return ApiResponse(STATUS_OK, message);
} else if (user && !user.confirmed) {
await prisma.user.update({
where: {
email
},
data: {
deleted: false
code
}
});
} else {
const contact = await resend.contacts.create({
email: email,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: true
});
if (!contact.data?.id) {
throw new Error('Failed to create Resend contact');
}
await prisma.user.create({
data: {
email,
code,
resendId: contact.data.id
}
});
}
const message: z.infer<typeof ResponseSchema> = {
const sent = await sender([email], ConfirmationTemplate(code));
if (!sent) {
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
const message: ResponseType = {
success: true,
message: `Thank you for subscribing!`
message: `Thank you! You will now receive an email to ${email} to confirm the subscription.`
};
return ApiResponse(200, JSON.stringify(message));
return ApiResponse(STATUS_OK, message);
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
const code = crypto
.createHash('sha256')
.update(`${process.env.SECRET_HASH}${email}}`)
.digest('hex');
await prisma.user.upsert({
create: {
email,
code
},
update: {
code
},
where: {
email
}
});
const sent = await sender([email], ConfirmationTemplate(code));
if (!sent) {
return ApiResponse(500, 'Internal server error');
}
const message: z.infer<typeof ResponseSchema> = {
success: true,
message: `Thank you! You will now receive an email to ${email} to confirm the subscription.`
};
return ApiResponse(200, JSON.stringify(message));
}

View File

@@ -1,47 +1,74 @@
import { z } from 'zod';
import UnsubscribeTemplate from '../../../components/emails/unsubscribe';
import prisma from '../../../prisma/prisma';
import { ApiResponse } from '../../../utils/apiResponse';
import { ResponseSchema, UnsubscribeFormSchema } from '../../../utils/schemas';
import { sender } from '../../../utils/sender';
import UnsubscribeTemplate from '@components/email/Unsubscribe';
import prisma from '@prisma/prisma';
import { ApiResponse } from '@utils/apiResponse';
import { sender } from '@utils/sender';
import {
BAD_REQUEST,
INTERNAL_SERVER_ERROR,
STATUS_BAD_REQUEST,
STATUS_INTERNAL_SERVER_ERROR,
STATUS_OK
} from '@utils/statusCodes';
import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas';
import { Resend } from 'resend';
export const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: Request) {
const body = await request.json();
const validation = UnsubscribeFormSchema.safeParse(body);
if (!validation.success) {
return ApiResponse(400, 'Bad request');
}
const { email } = validation.data;
const user = await prisma.user.findUnique({
where: {
email
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_AUDIENCE is not set');
}
const body = await request.json();
const validation = UnsubscribeFormSchema.safeParse(body);
if (!validation.success) {
return ApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
}
});
if (user && !user.deleted) {
await prisma.user.update({
const { email } = validation.data;
const user = await prisma.user.findUnique({
where: {
email
},
data: {
deleted: true
}
});
const sent = await sender([email], UnsubscribeTemplate());
if (user && !user.deleted) {
await prisma.user.update({
where: {
email
},
data: {
deleted: true
}
});
if (!sent) {
return ApiResponse(500, 'Internal server error');
const resend = new Resend(process.env.RESEND_KEY);
await resend.contacts.update({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: true
});
const sent = await sender([email], UnsubscribeTemplate());
if (!sent) {
return ApiResponse(
STATUS_INTERNAL_SERVER_ERROR,
'Internal server error'
);
}
}
const message: ResponseType = {
success: true,
message: `${email} unsubscribed.`
};
return ApiResponse(STATUS_OK, message);
} catch (error) {
console.error(error);
return ApiResponse(STATUS_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
}
const message: z.infer<typeof ResponseSchema> = {
success: true,
message: `${email} unsubscribed.`
};
return ApiResponse(200, JSON.stringify(message));
}

View File

@@ -1,9 +1,9 @@
'use client';
import { CardDescription } from '@components/Card';
import CustomCard from '@components/CustomCard';
import { ResponseType } from '@utils/validationSchemas';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { z } from 'zod';
import { Card } from '../../components/custom/card';
import { ResponseSchema } from '../../utils/schemas';
function ConfirmationPage() {
const router = useRouter();
@@ -14,49 +14,59 @@ function ConfirmationPage() {
const code = searchParams.get('code');
useEffect(() => {
if (!code) {
router.push('/');
}
const fetchData = async () => {
if (!code) {
router.push('/');
return;
}
try {
const res = await fetch('/api/confirmation', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code: code
})
});
fetch('/api/confirmation', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code: code
})
})
.then(async res => {
if (!res.ok) {
router.push('/');
return;
}
const response: z.infer<typeof ResponseSchema> = await res.json();
const response: ResponseType = await res.json();
if (!response.success) {
router.push('/');
return;
}
return response;
})
.then(response => {
setMessage(response.message);
setLoading(false);
});
} catch (error) {
console.error(error);
router.push('/');
}
};
fetchData();
}, [code, router]);
function render() {
if (!loading) {
return message;
return (
<CardDescription className='text-center'>{message}</CardDescription>
);
}
return 'Just a second...';
}
return (
<Card
style='text-center'
<CustomCard
className='max-90vw w-96'
title={loading ? 'Verifying' : 'Confirmed!'}
content={render()}
footer={false}
@@ -66,7 +76,7 @@ function ConfirmationPage() {
export default function Confirmation() {
return (
<Suspense>
<Suspense fallback={<>Loading...</>}>
<ConfirmationPage />
</Suspense>
);

View File

@@ -35,47 +35,25 @@
--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%;
h1 {
@apply text-3xl font-semibold;
}
}
.styledH2 {
@apply text-xl font-bold;
}
h2 {
@apply text-2xl font-semibold;
}
.styledH3 {
@apply text-lg font-semibold;
}
h3 {
@apply text-xl font-semibold;
}
.styledH4 {
@apply text-base font-medium;
h4 {
@apply text-lg font-semibold;
}
h6 {
@apply text-lg italic;
}
}
/* Card border gradient */
@@ -102,7 +80,6 @@ body {
content: '';
top: calc(-1 * var(--radius));
left: calc(-1 * var(--radius));
z-index: -1;
width: calc(100% + var(--radius) * 2);
height: calc(100% + var(--radius) * 2);
background: linear-gradient(

View File

@@ -1,8 +1,8 @@
import Tiles from '@components/tiles/Tiles';
import { cn } from '@utils/ui';
import { Analytics } from '@vercel/analytics/react';
import type { Metadata } from 'next';
import { Inter as FontSans } from 'next/font/google';
import { Tiles } from '../components/custom/tiles/tiles';
import { cn } from '../utils/ui';
import './globals.css';
export const metadata: Metadata = {
@@ -22,16 +22,16 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang='en' suppressHydrationWarning>
<html lang='en'>
<head />
<body
className={cn(
'flex min-h-screen items-center justify-center bg-background font-sans antialiased',
'flex justify-center bg-background font-sans antialiased',
fontSans.variable
)}
>
<Tiles>
<div style={{ zIndex: 2 }}>{children}</div>
<div className='z-10'>{children}</div>
</Tiles>
<Analytics />
</body>

View File

@@ -1,46 +1,35 @@
'use client';
import { Button } from '@components/Button';
import { CardDescription } from '@components/Card';
import CustomCard from '@components/CustomCard';
import ErrorMessage from '@components/ErrorMessage';
import { FormControl } from '@components/form/FormControl';
import { FormMessage } from '@components/form/FormMessage';
import { Input } from '@components/Input';
import { FormField } from '@contexts/FormField/FormFieldProvider';
import { FormItem } from '@contexts/FormItem/FormItemProvider';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Card } 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/schemas';
ResponseType,
SubscribeFormSchema,
SubscribeFormType
} from '@utils/validationSchemas';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
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>>({
const form = useForm<SubscribeFormType>({
resolver: zodResolver(SubscribeFormSchema),
defaultValues: {
email: '',
name: ''
email: ''
}
});
useEffect(() => {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
}
}, []);
async function handleSubmit(values: z.infer<typeof SubscribeFormSchema>) {
if (values.name) {
return;
}
async function handleSubmit(values: SubscribeFormType) {
try {
const response = await fetch('/api/subscribe', {
method: 'POST',
@@ -56,8 +45,7 @@ export default function Home() {
throw new Error(`Invalid response: ${response.status}`);
}
const formResponse: z.infer<typeof ResponseSchema> =
await response.json();
const formResponse: ResponseType = await response.json();
if (!formResponse.success) {
throw Error(formResponse.message);
@@ -76,12 +64,14 @@ export default function Home() {
}
if (completed) {
return message;
return (
<CardDescription className='text-center'>{message}</CardDescription>
);
}
return (
<div className='mx-2 h-44'>
<Form {...form}>
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='flex flex-col space-y-4'
@@ -91,7 +81,7 @@ export default function Home() {
name='email'
render={({ field }) => (
<FormItem>
<div className='h-4'>
<div className='h-6'>
<FormMessage className='text-center' />
</div>
<FormControl>
@@ -108,7 +98,7 @@ export default function Home() {
<Button type='submit'>Submit</Button>
</div>
</form>
</Form>
</FormProvider>
<p className='py-1 text-center text-xs text-gray-600'>
You can rest assured that we will fill your inbox with spam. We
don&apos;t like it either! 🙂
@@ -118,8 +108,8 @@ export default function Home() {
}
return (
<Card
style='text-center max-w-96'
<CustomCard
className='max-90vw w-96'
title='Interested in keeping up with the latest from the tech world? 👩‍💻'
description='Subscribe to our newsletter! The top stories from Hackernews for you. Once a day. Every day.'
content={render()}

View File

@@ -1,6 +1,7 @@
'use client';
import CustomCard from '@components/CustomCard';
import Link from 'next/link';
import { Card } from '../../components/custom/card';
export default function Privacy() {
const body = (
@@ -25,8 +26,8 @@ export default function Privacy() {
.
</p>
<br />
<h2 className='styledH2'>Interpretation and Definitions</h2>
<h3 className='styledH3'>Interpretation</h3>
<h2>Interpretation and Definitions</h2>
<h3>Interpretation</h3>
<p>
The words of which the initial letter is capitalized have meanings
defined under the following conditions. The following definitions shall
@@ -34,7 +35,7 @@ export default function Privacy() {
in plural.
</p>
<br />
<h3 className='styledH3'>Definitions</h3>
<h4>Definitions</h4>
<p>For the purposes of this Privacy Policy:</p>
<ul>
<li>
@@ -115,9 +116,9 @@ export default function Privacy() {
</li>
</ul>
<br />
<h2 className='styledH2'>Collecting and Using Your Personal Data</h2>
<h3 className='styledH3'>Types of Data Collected</h3>
<h4 className='styledH4'>Personal Data</h4>
<h3>Collecting and Using Your Personal Data</h3>
<h4>Types of Data Collected</h4>
<h6>Personal Data</h6>
<p>
While using Our Service, We may ask You to provide Us with certain
personally identifiable information that can be used to contact or
@@ -133,7 +134,7 @@ export default function Privacy() {
</li>
</ul>
<br />
<h4 className='styledH4'>Usage Data</h4>
<h6>Usage Data</h6>
<p>Usage Data is collected automatically when using the Service.</p>
<p>
Usage Data may include information such as Your Device&aposs Internet
@@ -156,7 +157,7 @@ export default function Privacy() {
device.
</p>
<br />
<h3 className='styledH3'>Use of Your Personal Data</h3>
<h4>Use of Your Personal Data</h4>
<p>The Company may use Personal Data for the following purposes:</p>
<ul>
<li>
@@ -265,7 +266,7 @@ export default function Privacy() {
</li>
</ul>
<br />
<h3 className='styledH3'>Retention of Your Personal Data</h3>
<h4>Retention of Your Personal Data</h4>
<p>
The Company will retain Your Personal Data only for as long as is
necessary for the purposes set out in this Privacy Policy. We will
@@ -282,7 +283,7 @@ export default function Privacy() {
data for longer time periods.
</p>
<br />
<h3 className='styledH3'>Transfer of Your Personal Data</h3>
<h4>Transfer of Your Personal Data</h4>
<p>
Your information, including Personal Data, is processed at the
Company&aposs operating offices and in any other places where the
@@ -304,7 +305,7 @@ export default function Privacy() {
security of Your data and other personal information.
</p>
<br />
<h3 className='styledH3'>Delete Your Personal Data</h3>
<h4>Delete Your Personal Data</h4>
<p>
You have the right to delete or request that We assist in deleting the
Personal Data that We have collected about You.
@@ -325,8 +326,8 @@ export default function Privacy() {
when we have a legal obligation or lawful basis to do so.
</p>
<br />
<h3 className='styledH3'>Disclosure of Your Personal Data</h3>
<h4 className='styledH4'>Business Transactions</h4>
<h4>Disclosure of Your Personal Data</h4>
<h6>Business Transactions</h6>
<p>
If the Company is involved in a merger, acquisition or asset sale, Your
Personal Data may be transferred. We will provide notice before Your
@@ -334,14 +335,14 @@ export default function Privacy() {
Policy.
</p>
<br />
<h4 className='styledH4'>Law enforcement</h4>
<h6>Law enforcement</h6>
<p>
Under certain circumstances, the Company may be required to disclose
Your Personal Data if required to do so by law or in response to valid
requests by public authorities (e.g. a court or a government agency).
</p>
<br />
<h4 className='styledH4'>Other legal requirements</h4>
<h6>Other legal requirements</h6>
<p>
The Company may disclose Your Personal Data in the good faith belief
that such action is necessary to:
@@ -358,9 +359,8 @@ export default function Privacy() {
</li>
<li>Protect against legal liability</li>
</ul>
<br />
<h3 className='styledH3'>Security of Your Personal Data</h3>
<h4>Security of Your Personal Data</h4>
<p>
The security of Your Personal Data is important to Us, but remember that
no method of transmission over the Internet, or method of electronic
@@ -369,7 +369,7 @@ export default function Privacy() {
security.
</p>
<br />
<h2 className='styledH2'>{"Children's Privacy"}</h2>
<h3>{"Children's Privacy"}</h3>
<p>
Our Service does not address anyone under the age of 13. We do not
knowingly collect personally identifiable information from anyone under
@@ -386,7 +386,7 @@ export default function Privacy() {
information.
</p>
<br />
<h2 className='styledH2'>Links to Other Websites</h2>
<h3>Links to Other Websites</h3>
<p>
Our Service may contain links to other websites that are not operated by
Us. If You click on a third party link, You will be directed to that
@@ -398,7 +398,7 @@ export default function Privacy() {
privacy policies or practices of any third party sites or services.
</p>
<br />
<h2 className='styledH2'>Changes to this Privacy Policy</h2>
<h3>Changes to this Privacy Policy</h3>
<p>
We may update Our Privacy Policy from time to time. We will notify You
of any changes by posting the new Privacy Policy on this page.
@@ -414,7 +414,7 @@ export default function Privacy() {
posted on this page.
</p>
<br />
<h2 className='styledH2'>Contact Us</h2>
<h3>Contact Us</h3>
<p>
If you have any questions about this Privacy Policy, You can contact us
by writing to{' '}
@@ -439,8 +439,8 @@ export default function Privacy() {
);
return (
<Card
style='max-h-[90vh] max-w-[90vw]'
<CustomCard
className='max-90vh max-90vw'
title='Privacy Policy'
description='Last updated: December 03, 2023'
content={body}

View File

@@ -4,7 +4,7 @@ export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
disallow: '/'
disallow: ''
},
sitemap: `${process.env.HOME_URL!}/sitemap.xml`
};

View File

@@ -1,46 +1,42 @@
'use client';
import { Button } from '@components/Button';
import { CardDescription } from '@components/Card';
import CustomCard from '@components/CustomCard';
import ErrorMessage from '@components/ErrorMessage';
import { FormControl } from '@components/form/FormControl';
import { FormMessage } from '@components/form/FormMessage';
import { Input } from '@components/Input';
import { FormField } from '@contexts/FormField/FormFieldProvider';
import { FormItem } from '@contexts/FormItem/FormItemProvider';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Card } 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/schemas';
ResponseType,
UnsubscribeFormSchema,
UnsubscribeFormType
} from '@utils/validationSchemas';
import { useEffect, useRef, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
export default function Unsubscribe() {
const [completed, setCompleted] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState(false);
const honeypotRef = useRef<HTMLInputElement | null>(null);
const ref = useRef<HTMLInputElement | null>(null);
const form = useForm<z.infer<typeof UnsubscribeFormSchema>>({
const form = useForm<UnsubscribeFormType>({
resolver: zodResolver(UnsubscribeFormSchema),
defaultValues: {
email: '',
name: ''
email: ''
}
});
useEffect(() => {
if (honeypotRef.current) {
honeypotRef.current.style.display = 'none';
if (ref.current) {
ref.current.style.display = 'none';
}
}, []);
async function handleSubmit(values: z.infer<typeof UnsubscribeFormSchema>) {
if (values.name) {
return;
}
async function handleSubmit(values: UnsubscribeFormType) {
try {
const response = await fetch('/api/unsubscribe', {
method: 'POST',
@@ -56,8 +52,7 @@ export default function Unsubscribe() {
throw new Error(`Invalid response: ${response.status}`);
}
const formResponse: z.infer<typeof ResponseSchema> =
await response.json();
const formResponse: ResponseType = await response.json();
if (!formResponse.success) {
throw Error(formResponse.message);
@@ -76,12 +71,14 @@ export default function Unsubscribe() {
}
if (completed) {
return message;
return (
<CardDescription className='text-center'>{message}</CardDescription>
);
}
return (
<div className='mb-5 h-32'>
<Form {...form}>
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='flex flex-col space-y-4'
@@ -91,11 +88,15 @@ export default function Unsubscribe() {
name='email'
render={({ field }) => (
<FormItem>
<div className='h-4'>
<div className='h-6'>
<FormMessage className='text-center' />
</div>
<FormControl>
<Input placeholder='example@example.com' {...field} />
<Input
placeholder='example@example.com'
className='text-center'
{...field}
/>
</FormControl>
</FormItem>
)}
@@ -104,14 +105,14 @@ export default function Unsubscribe() {
<Button type='submit'>Submit</Button>
</div>
</form>
</Form>
</FormProvider>
</div>
);
}
return (
<Card
style='text-center max-w-80'
<CustomCard
className='max-90vw w-96'
title='Unsubscribe'
description='You sure you want to leave? :('
content={render()}

View File

@@ -1,6 +1,6 @@
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@utils/ui';
import * as React from 'react';
import { cn } from '../../utils/ui';
export type ButtonProps = {
asChild?: boolean;

View File

@@ -1,5 +1,5 @@
import { cn } from '@utils/ui';
import * as React from 'react';
import { cn } from '../../utils/ui';
const Card = React.forwardRef<
HTMLDivElement,
@@ -7,10 +7,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
className={cn('rounded-lg bg-card text-card-foreground', className)}
{...props}
/>
));
@@ -32,12 +29,9 @@ const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
<h1
ref={ref}
className={cn(
'text-3xl font-semibold leading-none tracking-tight',
className
)}
className={cn('leading-none tracking-tight', className)}
{...props}
/>
));

View File

@@ -1,32 +1,30 @@
import { ReactNode, useEffect, useState } from 'react';
import { useMediaQuery } from 'react-responsive';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Card as CardUI
} from '../../components/ui/card';
import Footer from './footer';
CardTitle
} from './Card';
import Footer from './Footer';
type CardProps = {
interface CardProps {
title: string;
description?: string;
content: ReactNode;
style?: string;
className?: string;
footer?: boolean;
};
}
export const Card = ({
export default function CustomCard({
title,
description,
content,
style,
className,
footer = true
}: CardProps) => {
}: CardProps) {
const [isLoaded, setIsLoaded] = useState(false);
const isMobile = useMediaQuery({ query: '(max-width: 767px)' });
useEffect(() => {
setIsLoaded(true);
@@ -37,13 +35,8 @@ export const Card = ({
}
return (
<div className='gradient-border'>
<CardUI
style={{
boxShadow: '0 16px 32px 0 rgba(0, 0, 0, 0.6)'
}}
className={`max-h-[90vh] max-w-[90vw] p-8 ${style}`}
>
<div className='gradient-border shadow-2xl shadow-black'>
<Card className={`z-10 max-w-[90vw] p-8 ${className}`}>
<CardHeader>
<p className='text-xs uppercase text-gray-500'>
Hackernews + newsletter
@@ -51,19 +44,13 @@ export const Card = ({
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
{isMobile ? (
<CardContent>{content}</CardContent>
) : (
<CardContent className='flex max-h-[60vh] flex-grow justify-center overflow-auto'>
{content}
</CardContent>
)}
<CardContent>{content}</CardContent>
{footer && (
<CardFooter>
<Footer />
</CardFooter>
)}
</CardUI>
</Card>
</div>
);
};
}

View File

@@ -1,13 +1,13 @@
'use client';
import Link from 'next/link';
import { Button } from '../ui/button';
import { Button } from './Button';
type LinkProps = {
interface LinkProps {
path: string;
text: string;
};
}
export function CustomLink({ path, text }: LinkProps) {
export default function CustomLink({ path, text }: LinkProps) {
return (
<Button asChild>
<Link href={path}>{text}</Link>

View File

@@ -1,11 +1,11 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CustomLink } from './customLink';
import CustomLink from './CustomLink';
const links = [{ name: 'Subscribe', path: '/' }];
function Footer() {
export default function Footer() {
const pathname = usePathname();
return (
@@ -22,7 +22,7 @@ function Footer() {
.
</p>
) : (
<ul className='flex justify-center space-x-4'>
<div className='flex justify-center space-x-4'>
{links.map(
link =>
pathname !== link.path &&
@@ -33,10 +33,8 @@ function Footer() {
{pathname === '/privacy' && (
<CustomLink path='/unsubscribe' text='Unsubscribe' />
)}
</ul>
</div>
)}
</div>
);
}
export default Footer;

View File

@@ -1,5 +1,5 @@
import { cn } from '@utils/ui';
import * as React from 'react';
import { cn } from '../../utils/ui';
const Input = React.forwardRef<
HTMLInputElement,
@@ -9,7 +9,7 @@ const Input = React.forwardRef<
<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',
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View File

@@ -1,9 +1,9 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@utils/ui';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../../utils/ui';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'

View File

@@ -1,48 +0,0 @@
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { NewsTileSchema } from '../../../../utils/schemas';
import { TileContent } from './tileContent';
type CardProps = {
newsA?: z.infer<typeof NewsTileSchema>;
newsB?: z.infer<typeof NewsTileSchema>;
};
export function Tile({ newsA, newsB }: CardProps) {
const [switched, setSwitched] = useState(false);
const [active, setActive] = useState(Math.random() < 0.5);
const [delayed, setDelayed] = useState(true);
useEffect(() => {
const randomDelay = Math.floor(Math.random() * 10000);
const interval = setInterval(
() => {
setSwitched(true);
window.setTimeout(function () {
setSwitched(false);
setActive(!active);
setDelayed(false);
}, 500 / 2);
},
delayed ? randomDelay : randomDelay + 10000
);
return () => clearInterval(interval);
}, [active, delayed]);
if (!newsA || !newsB) return <div></div>;
return (
<div className={`transform ${switched ? 'animate-rotate' : ''}`}>
<div className='transform-gpu'>
<div className={`absolute left-0 top-0 w-full ${''}`}>
{active
? TileContent({ story: newsA, side: true })
: TileContent({ story: newsB, side: false })}
</div>
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
import { useState } from 'react';
import { z } from 'zod';
import { getRandomGrey } from '../../../../utils/getRandomGrey';
import { NewsTileSchema } from '../../../../utils/schemas';
type CardContentProps = {
story: z.infer<typeof NewsTileSchema>;
side: boolean;
};
export function TileContent({ story, side }: CardContentProps) {
const [firstColor, setFirstColor] = useState(getRandomGrey());
const [secondColor, setSecondColor] = useState(getRandomGrey());
const [switched, setSwitched] = useState(true);
if (switched !== side) {
setFirstColor(getRandomGrey());
setSecondColor(getRandomGrey());
setSwitched(side);
}
const color = side ? firstColor : secondColor;
return (
<div
className={`h-40 w-40 overflow-hidden rounded-lg p-6 shadow-sm`}
style={{
backgroundColor: `${color}`,
color: '#808080'
}}
>
<h1 className='overflow-auto font-semibold'>{story.title}</h1>
<p className='overflow-auto italic'>by {story.by}</p>
<div
className='rounded-lg'
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '33.33%',
background: `linear-gradient(to bottom, transparent, ${color})`
}}
></div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Note } from './components/note';
import Template from './template';
import Note from './components/Note';
import Template from './Template';
export default function ConfirmationTemplate(code: string) {
return {

View File

@@ -1,12 +1,9 @@
import { z } from 'zod';
import { NewsSchema } from '../../utils/schemas';
import { textTruncate } from '../../utils/textTruncate';
import { sayings } from './helpers/sayings';
import Template from './template';
import { sayings } from '@utils/sayings';
import { textTruncate } from '@utils/textTruncate';
import { NewsType } from '@utils/validationSchemas';
import Template from './Template';
export default function NewsletterTemplate(
stories: z.infer<typeof NewsSchema>[]
) {
export default function NewsletterTemplate(stories: NewsType[]) {
return {
subject: `What's new from the Hackernews forum?`,
template: (
@@ -40,9 +37,7 @@ export default function NewsletterTemplate(
paddingRight: '1.5rem'
}}
>
<h2 style={{ fontSize: '1.5rem', fontWeight: '600' }}>
{story.title}
</h2>
<h3>{story.title}</h3>
<p style={{ fontSize: '1rem', fontStyle: 'italic' }}>
by {story.by}
</p>

View File

@@ -1,9 +1,9 @@
import { Footer } from './components/footer';
import Footer from './components/Footer';
type TemplateProps = {
interface TemplateProps {
title: string;
body: JSX.Element;
};
}
export default function Template({ title, body }: TemplateProps) {
return (
@@ -12,21 +12,19 @@ export default function Template({ title, body }: TemplateProps) {
maxWidth: '720px',
alignContent: 'center',
alignItems: 'center',
backgroundColor: '#F9FBFB',
backgroundColor: '#F9FBFB'
}}
>
<h1
<h2
style={{
padding: '20px',
textAlign: 'center',
fontSize: '24px',
fontWeight: 'bold',
color: 'white',
backgroundColor: `#8230CC`,
backgroundColor: `#8230CC`
}}
>
{title}
</h1>
</h2>
<div style={{ margin: '20px', padding: '20px' }}>{body}</div>
<Footer />
</div>

View File

@@ -1,5 +1,5 @@
import { Note } from './components/note';
import Template from './template';
import Note from './components/Note';
import Template from './Template';
export default function UnsubscribeTemplate() {
return {

View File

@@ -1,4 +1,4 @@
export function Footer() {
export default function Footer() {
return (
<footer
style={{

View File

@@ -1,8 +1,8 @@
type NoteProps = {
interface NoteProps {
children: React.ReactNode;
};
}
export function Note({ children }: NoteProps) {
export default function Note({ children }: NoteProps) {
return (
<div
style={{

View File

@@ -0,0 +1,26 @@
import { useFormField } from '@hooks/useFormField';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
export 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';

View File

@@ -0,0 +1,20 @@
import { useFormField } from '@hooks/useFormField';
import { cn } from '@utils/ui';
import * as React from 'react';
export const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';

View File

@@ -0,0 +1,22 @@
import { useFormField } from '@hooks/useFormField';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@utils/ui';
import * as React from 'react';
import { Label } from '../Label';
export 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';

View File

@@ -0,0 +1,28 @@
import { useFormField } from '@hooks/useFormField';
import { cn } from '@utils/ui';
import * as React from 'react';
export const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
return (
<>
{!body ? null : (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
)}
</>
);
});
FormMessage.displayName = 'FormMessage';

View File

@@ -1,16 +1,15 @@
'use client';
import { NewsTileType } from '@utils/validationSchemas';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { NewsTile, NewsTileSchema } from '../../../utils/schemas';
import { Tile } from './components/tile';
import { useCallback, useEffect, useState } from 'react';
import Tile from './components/Tile';
type TilesProps = {
interface TilesProps {
children: React.ReactNode;
};
}
export const Tiles = ({ children }: TilesProps) => {
export default function Tiles({ children }: TilesProps) {
const pathname = usePathname();
const [windowSize, setWindowSize] = useState<{
width: number;
@@ -19,15 +18,15 @@ export const Tiles = ({ children }: TilesProps) => {
width: 0,
height: 0
});
const [news, setNews] = useState<z.infer<typeof NewsTileSchema>[]>();
const [news, setNews] = useState<NewsTileType[]>();
useEffect(() => {
async function getNews() {
const news: NewsTile[] = await fetch('/api/news').then(res => res.json());
const news: NewsTileType[] = await fetch('/api/news').then(res =>
res.json()
);
if (news) {
setNews(news);
}
setNews(news);
}
if (!news) {
@@ -50,34 +49,38 @@ export const Tiles = ({ children }: TilesProps) => {
return () => {
window.removeEventListener('resize', handleResize);
};
}, [setWindowSize, news]);
}, [news]);
if (pathname === '/maintenance') return <div>{children}</div>;
const renderTile = useCallback(
(key: number) => {
if (!news) return <div key={key}></div>;
function renderTile(key: number) {
if (!news) return <div key={key}></div>;
const randomA = Math.floor(Math.random() * news?.length);
const randomB = Math.floor(
Math.random() * news?.filter((_, index) => index !== randomA)?.length
);
const randomA = Math.floor(Math.random() * news?.length);
const randomB = Math.floor(
Math.random() * news?.filter((_, index) => index !== randomA)?.length
);
return (
<div key={key} className={`m-1 h-40 w-40`}>
<Tile newsA={news[randomA]} newsB={news[randomB]} />
</div>
);
},
[news]
);
return (
<div key={key} className={`m-1 h-40 w-40`}>
<Tile newsA={news[randomA]} newsB={news[randomB]} />
</div>
);
}
const renderRow = useCallback(
(columns: number, key: number) => {
return (
<div key={key} className='flex justify-between'>
{Array.from({ length: columns }).map((_, index) => renderTile(index))}
</div>
);
},
[renderTile]
);
function renderRow(columns: number, key: number) {
return (
<div key={key} className='flex justify-between'>
{Array.from({ length: columns }).map((_, index) => renderTile(index))}
</div>
);
}
function renderGrid() {
const renderGrid = useCallback(() => {
const columns = Math.ceil(windowSize.width / (40 * 4));
const rows = Math.ceil(windowSize.height / (40 * 4));
@@ -93,7 +96,9 @@ export const Tiles = ({ children }: TilesProps) => {
</div>
</div>
);
}
}, [children, renderRow, windowSize]);
if (pathname === '/maintenance') return <div>{children}</div>;
return <div className='flex h-[100vh] overflow-hidden'>{renderGrid()}</div>;
};
}

View File

@@ -0,0 +1,68 @@
import { getRandomGrey } from '@utils/getRandomGrey';
import { NewsTileType } from '@utils/validationSchemas';
import { useEffect, useState } from 'react';
import TileContent from './TileContent';
interface CardProps {
newsA?: NewsTileType;
newsB?: NewsTileType;
}
const TEN_SECONDS = 10000;
const HALF_SECOND = 500;
export default function Tile({ newsA, newsB }: CardProps) {
const [switched, setSwitched] = useState(false);
const [active, setActive] = useState(false);
const [delayed, setDelayed] = useState(true);
const [colorA, setColorA] = useState(getRandomGrey());
const [colorB, setColorB] = useState(getRandomGrey());
useEffect(() => {
const randomDelay = Math.floor(Math.random() * TEN_SECONDS);
const interval = setInterval(
() => {
setSwitched(true);
window.setTimeout(function () {
setActive(!active);
setColorA(getRandomGrey());
setColorB(getRandomGrey());
}, HALF_SECOND / 2);
window.setTimeout(function () {
setSwitched(false);
setDelayed(false);
}, HALF_SECOND);
},
delayed ? randomDelay : randomDelay + TEN_SECONDS
);
return () => clearInterval(interval);
}, [active, delayed]);
if (!newsA || !newsB) return <div></div>;
return (
<div className={`transform ${switched ? 'animate-rotate' : ''}`}>
<div className='transform-gpu'>
<div className={`absolute left-0 top-0 w-full ${''}`}>
{active
? TileContent({
story: newsA,
side: true,
firstColor: colorA,
secondColor: colorB
})
: TileContent({
story: newsB,
side: false,
firstColor: colorB,
secondColor: colorA
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { NewsTileType } from '@utils/validationSchemas';
interface CardContentProps {
story: NewsTileType;
side: boolean;
firstColor: string;
secondColor: string;
}
export default function TileContent({
story,
side,
firstColor,
secondColor
}: CardContentProps) {
const color = side ? firstColor : secondColor;
return (
<div
className={`h-40 w-40 overflow-hidden rounded-lg p-6 shadow-sm`}
style={{
backgroundColor: `${color}`,
color: '#808080'
}}
>
<h4 className='overflow-auto'>{story.title}</h4>
<p className='overflow-auto italic'>by {story.by}</p>
<div
className='rounded-lg'
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '33.33%',
background: `linear-gradient(to bottom, transparent, ${color})`
}}
></div>
</div>
);
}

View File

@@ -1,177 +0,0 @@
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/ui';
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-sm text-muted-foreground', 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;
return (
<>
{!body ? null : (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
)}
</>
);
});
FormMessage.displayName = 'FormMessage';
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField
};

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { FieldPath, FieldValues } from 'react-hook-form';
interface FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> {
name: TName;
}
export const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);

View File

@@ -0,0 +1,20 @@
import {
Controller,
ControllerProps,
FieldPath,
FieldValues
} from 'react-hook-form';
import { FormFieldContext } from './FormFieldContext';
export 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>
);
};

View File

@@ -0,0 +1,9 @@
import React from 'react';
interface FormItemContextValue {
id: string;
}
export const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);

View File

@@ -0,0 +1,18 @@
import { cn } from '@utils/ui';
import * as React from 'react';
import { FormItemContext } from './FormItemContext';
export 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';

29
hooks/useFormField.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { FormFieldContext } from '@contexts/FormField/FormFieldContext';
import { FormItemContext } from '@contexts/FormItem/FormItemContext';
import * as React from 'react';
import { useFormContext } from 'react-hook-form';
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
};
};
export { useFormField };

View File

@@ -1,8 +1,8 @@
{
"name": "nextjs-hackernews",
"version": "0.2.0",
"version": "0.3.0",
"description": "Template for NodeJS APIs with TypeScript, with configurations for linting and testing",
"author": "riccardo.s@hey.com",
"author": "riccardo@frompixels.com",
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
@@ -13,16 +13,17 @@
"prepare": "husky install",
"vercel:link": "vercel link",
"vercel:env": "vercel env pull .env",
"prisma:migrate": "npx prisma migrate dev",
"prisma:push": "npx prisma db push",
"prisma:generate": "npx prisma generate",
"prisma:reset": "npx prisma db push --force-reset"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@next/third-parties": "^14.2.3",
"@prisma/client": "^5.6.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@vercel/analytics": "^1.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
@@ -31,7 +32,6 @@
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.48.2",
"react-responsive": "^9.0.2",
"resend": "^3.1.0",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
@@ -43,6 +43,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"audit-ci": "^6.6.1",
"autoprefixer": "^10.0.1",
@@ -59,10 +60,10 @@
"typescript": "^5"
},
"lint-staged": {
"*.ts": [
"*.{ts,tsx}": [
"eslint --quiet --fix"
],
"*.{json,ts}": [
"*.{json,ts,tsx}": [
"prettier --write --ignore-unknown"
]
}

View File

@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"resendId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"code" TEXT NOT NULL,
"confirmed" BOOLEAN NOT NULL DEFAULT false,
"deleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastMail" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "news" (
"id" DOUBLE PRECISION NOT NULL,
"title" TEXT NOT NULL,
"text" TEXT,
"type" TEXT NOT NULL,
"by" TEXT NOT NULL,
"time" DOUBLE PRECISION NOT NULL,
"url" TEXT,
"score" DOUBLE PRECISION NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "news_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_code_key" ON "users"("code");
-- CreateIndex
CREATE UNIQUE INDEX "news_id_key" ON "news"("id");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -9,12 +9,13 @@ datasource db {
}
model User {
id String @id @default(cuid())
email String @unique
code String @unique
confirmed Boolean @default(false)
deleted Boolean @default(false)
createdAt DateTime @default(now())
id String @id @default(cuid())
resendId String
email String @unique
code String @unique
confirmed Boolean @default(false)
deleted Boolean @default(false)
createdAt DateTime @default(now())
lastMail DateTime?
@@map(name: "users")

View File

@@ -4,8 +4,8 @@
<script>
window.onload = function () {
var body = document.getElementsByTagName('body')[0];
var size = 160; // size of each square
var margin = 8; // margin between squares
var size = 160;
var margin = 8;
var squaresPerRow = Math.ceil(window.innerWidth / (size + margin));
var squaresPerColumn = Math.ceil(window.innerHeight / (size + margin));

View File

@@ -54,28 +54,14 @@ module.exports = {
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 }
},
rotate: {
'0%': { transform: 'rotateY(0deg)' },
'100%': { transform: 'rotateY(180deg)' }
},
'rotate-inverse': {
'0%': { transform: 'rotateY(180deg)' },
'50%': { transform: 'rotateY(90deg)' },
'100%': { transform: 'rotateY(0deg)' }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
rotate: 'rotate 0.5s linear both',
'rotate-inverse': 'rotate-inverse 0.5s linear both'
rotate: 'rotate 0.5s linear both'
},
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans]

View File

@@ -19,9 +19,14 @@
}
],
"paths": {
"@/*": ["./app/*"]
"@app/*": ["./app/*"],
"@components/*": ["./components/*"],
"@contexts/*": ["./contexts/*"],
"@hooks/*": ["./hooks/*"],
"@prisma/*": ["./prisma/*"],
"@utils/*": ["./utils/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", ".next", ".vercel"]
"exclude": ["node_modules", ".yarn", ".next", ".vercel"]
}

View File

@@ -1,8 +1,7 @@
import { NextResponse } from 'next/server';
export function ApiResponse(status: number, message: string) {
const response = new NextResponse(message, { status });
response.headers.set('Access-Control-Allow-Origin', process.env.HOME_URL!);
export function ApiResponse(status: number, message: unknown) {
const stringMessage = JSON.stringify(message);
return response;
return new NextResponse(stringMessage, { status });
}

View File

@@ -1,43 +1,61 @@
import { Resend } from 'resend';
type EmailTemplate = {
interface EmailTemplate {
subject: string;
template: JSX.Element;
};
}
export async function sender(
to: string[],
recipients: string[],
{ subject, template }: EmailTemplate
) {
if (recipients.length === 0) {
console.info(`${subject} email skipped for having zero recipients`);
return true;
}
const resend = new Resend(process.env.RESEND_KEY);
try {
const { error } = await resend.batch.send(
to.map(t => {
return {
from: process.env.RESEND_FROM!,
to: t,
subject,
react: template,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
}
};
})
);
let response;
if (recipients.length == 1) {
response = await resend.emails.send({
from: process.env.RESEND_FROM!,
to: recipients[0],
subject,
react: template,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
}
});
} else {
response = await resend.batch.send(
recipients.map(recipient => {
return {
from: process.env.RESEND_FROM!,
to: recipient,
subject,
react: template,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
}
};
})
);
}
const { error } = response;
if (error) {
console.log(error);
console.error(error);
return false;
}
} catch (error) {
console.log(error);
console.info(`${subject} email sent to ${recipients.length} recipients`);
return true;
} catch (error) {
console.error(error);
return false;
}
console.log('Email sent', subject, to.length);
return true;
}

6
utils/statusCodes.ts Normal file
View File

@@ -0,0 +1,6 @@
export const STATUS_OK = 200;
export const STATUS_BAD_REQUEST = 400;
export const BAD_REQUEST = 'Bad request';
export const STATUS_INTERNAL_SERVER_ERROR = 500;
export const INTERNAL_SERVER_ERROR = 'Internal server error';
export const STATUS_UNAUTHORIZED = 401;

View File

@@ -1,3 +1,5 @@
export const topNews = 'https://hacker-news.firebaseio.com/v0/topstories.json';
export const singleNews = (id: number) =>
`https://hacker-news.firebaseio.com/v0/item/${id}.json`;
export function singleNews(id: number) {
return `https://hacker-news.firebaseio.com/v0/item/${id}.json`;
}

View File

@@ -5,20 +5,24 @@ export const ResponseSchema = z.object({
message: z.string()
});
export type ResponseType = z.infer<typeof ResponseSchema>;
export const SubscribeFormSchema = z.object({
email: z.string().email(),
name: z.string().optional()
email: z.string().email()
});
export type SubscribeFormType = z.infer<typeof SubscribeFormSchema>;
export const ConfirmationSchema = z.object({
code: z.string()
});
export const UnsubscribeFormSchema = z.object({
email: z.string().email(),
name: z.string().optional()
email: z.string().email()
});
export type UnsubscribeFormType = z.infer<typeof UnsubscribeFormSchema>;
export const NewsDatabaseSchema = z.object({
id: z.number(),
title: z.string(),
@@ -30,6 +34,8 @@ export const NewsDatabaseSchema = z.object({
score: z.number()
});
export type NewsDatabaseType = z.infer<typeof NewsDatabaseSchema>;
export const NewsSchema = z.object({
id: z.number(),
title: z.string(),
@@ -42,10 +48,12 @@ export const NewsSchema = z.object({
createdAt: z.date()
});
export type NewsType = z.infer<typeof NewsSchema>;
export const NewsTileSchema = z.object({
id: z.number(),
title: z.string(),
by: z.string()
});
export type NewsTile = z.infer<typeof NewsTileSchema>;
export type NewsTileType = z.infer<typeof NewsTileSchema>;

1323
yarn.lock

File diff suppressed because it is too large Load Diff