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,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

@@ -1,13 +0,0 @@
export const sayings = [
'hot off the press',
'straight from the oven',
"straight from the horse's mouth",
'brand spanking new',
'fresh as a daisy',
'straight out of the box',
'straight off the assembly line',
'hot out of the kitchen',
'just minted',
'freshly brewed',
'just off the production line'
];

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