From 5b7ccf0b671e2999b62befc729a3e517a0433728 Mon Sep 17 00:00:00 2001 From: Bertrand Yuan Date: Mon, 15 Dec 2025 23:48:10 +0800 Subject: initial commit -- the front-end prototype The initial code is base on Anirudh's work. More to see at: https://github.com/techwithanirudh/shadcn-blog Therefore, the code in this commit is under MIT license. --- src/lib/auth-client.ts | 24 ++++++++++++ src/lib/constants.ts | 5 +++ src/lib/is-active.ts | 15 ++++++++ src/lib/metadata-image.ts | 7 ++++ src/lib/metadata.ts | 30 +++++++++++++++ src/lib/resend.ts | 80 ++++++++++++++++++++++++++++++++++++++++ src/lib/safe-action.ts | 18 +++++++++ src/lib/source.ts | 40 ++++++++++++++++++++ src/lib/utils.ts | 6 +++ src/lib/validators/index.ts | 1 + src/lib/validators/newsletter.ts | 6 +++ 11 files changed, 232 insertions(+) create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/constants.ts create mode 100644 src/lib/is-active.ts create mode 100644 src/lib/metadata-image.ts create mode 100644 src/lib/metadata.ts create mode 100644 src/lib/resend.ts create mode 100644 src/lib/safe-action.ts create mode 100644 src/lib/source.ts create mode 100644 src/lib/utils.ts create mode 100644 src/lib/validators/index.ts create mode 100644 src/lib/validators/newsletter.ts (limited to 'src/lib') diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..aec3cf4 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,24 @@ +import { inferAdditionalFields } from 'better-auth/client/plugins'; +import { createAuthClient } from 'better-auth/react'; +import { toast } from 'sonner'; + +import type { auth } from '@/server/auth'; + +// @see https://github.com/better-auth/better-auth/issues/1391 +const authClient: ReturnType = createAuthClient({ + plugins: [inferAdditionalFields()], + // baseURL: env.BETTER_AUTH_URL, + fetchOptions: { + onError(e) { + if (e.error.status === 429) { + toast.error('Too many requests. Please try again later.'); + } + }, + }, +}); + +export const signIn: typeof authClient.signIn = authClient.signIn; +export const signOut: typeof authClient.signOut = authClient.signOut; +export const useSession: typeof authClient.useSession = authClient.useSession; + +export type User = (typeof authClient.$Infer.Session)['user']; diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..1271197 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,5 @@ +export const isProduction = process.env.NODE_ENV === 'production'; +export const baseUrl = + !isProduction || !process.env.VERCEL_PROJECT_PRODUCTION_URL + ? new URL('http://localhost:3000') + : new URL(`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`); diff --git a/src/lib/is-active.ts b/src/lib/is-active.ts new file mode 100644 index 0000000..f931065 --- /dev/null +++ b/src/lib/is-active.ts @@ -0,0 +1,15 @@ +export function isActive( + url: string, + pathname: string, + nested = true, +): boolean { + const normalizedUrl = url.endsWith('/') ? url.slice(0, -1) : url; + const normalizedPathname = pathname.endsWith('/') + ? pathname.slice(0, -1) + : pathname; + + return ( + normalizedUrl === normalizedPathname || + (nested && normalizedPathname.startsWith(`${normalizedUrl}/`)) + ); +} diff --git a/src/lib/metadata-image.ts b/src/lib/metadata-image.ts new file mode 100644 index 0000000..f2b91b6 --- /dev/null +++ b/src/lib/metadata-image.ts @@ -0,0 +1,7 @@ +import { source } from '@/lib/source'; +import { createMetadataImage } from 'fumadocs-core/server'; + +export const metadataImage = createMetadataImage({ + source, + imageRoute: 'og', +}); diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts new file mode 100644 index 0000000..8a2bff2 --- /dev/null +++ b/src/lib/metadata.ts @@ -0,0 +1,30 @@ +import type { Metadata } from 'next/types'; + +export function createMetadata(override: Metadata): Metadata { + return { + ...override, + openGraph: { + title: override.title ?? undefined, + description: override.description ?? undefined, + url: 'https://blog.techwithanirudh.com', + images: '/banner.png', + siteName: 'Blog', + ...override.openGraph, + }, + twitter: { + card: 'summary_large_image', + creator: '@AnirudhWith', + title: override.title ?? undefined, + description: override.description ?? undefined, + images: '/banner.png', + ...override.twitter, + }, + alternates: { + canonical: '/', + types: { + 'application/rss+xml': '/api/rss.xml', + }, + ...override.alternates, + }, + }; +} diff --git a/src/lib/resend.ts b/src/lib/resend.ts new file mode 100644 index 0000000..5ab7032 --- /dev/null +++ b/src/lib/resend.ts @@ -0,0 +1,80 @@ +import { baseUrl } from '@/lib/constants'; +import { Resend, type UpdateContactOptions } from 'resend'; +import NewsletterWelcomeEmail from '../../emails/newsletter-welcome'; +import type { getPosts } from './source'; + +const resend = new Resend(process.env.RESEND_API_KEY as string); + +export async function updateContact({ + email, + audienceId, + ...props +}: { + email: string; + audienceId: string; +} & Omit) { + const { data, error } = await resend.contacts.update({ + email, + audienceId, + ...props, + }); + + if (!data || error) { + if (error?.name === 'not_found') { + return null; + } + throw new Error(`Failed to update contact: ${error?.message}`); + } + + return data; +} + +export async function getContact({ + email, + audienceId, +}: { + email: string; + audienceId: string; +}) { + const { data: contacts, error } = await resend.contacts.list({ audienceId }); + + if (error || !contacts) { + throw new Error( + `Failed to list contacts: ${error?.message || 'Unknown error'}`, + ); + } + + const contact = contacts.data.find((contact) => contact.email === email); + return contact || null; +} + +export async function sendWelcomeEmail({ + posts, + firstName, + to, +}: { + posts: ReturnType; + firstName: string; + to: string; +}) { + const EMAIL_FROM = process.env.EMAIL_FROM as string; + if (!EMAIL_FROM) throw new Error('Missing EMAIL_FROM environment variable'); + if (!firstName || !to) throw new Error('Missing required email fields'); + + const formattedPosts = posts.map((post) => ({ + ...post.data, + image: `${baseUrl}${post.data.image}`, + url: `${baseUrl}${post.url}`, + })); + + const { data: res, error } = await resend.emails.send({ + from: EMAIL_FROM, + to, + subject: 'Welcome to my newsletter!', + react: NewsletterWelcomeEmail({ firstName, posts: formattedPosts }), + }); + + if (error) { + throw new Error(`Failed to send welcome email: ${JSON.stringify(error)}`); + } +} diff --git a/src/lib/safe-action.ts b/src/lib/safe-action.ts new file mode 100644 index 0000000..846f2ef --- /dev/null +++ b/src/lib/safe-action.ts @@ -0,0 +1,18 @@ +import { + DEFAULT_SERVER_ERROR_MESSAGE, + createSafeActionClient, +} from 'next-safe-action'; + +export class ActionError extends Error {} + +export const actionClient = createSafeActionClient({ + handleServerError(e) { + console.error('Failed to execute action:', e.message); + + if (e instanceof ActionError) { + return e.message; + } + + return DEFAULT_SERVER_ERROR_MESSAGE; + }, +}); diff --git a/src/lib/source.ts b/src/lib/source.ts new file mode 100644 index 0000000..34bc7ac --- /dev/null +++ b/src/lib/source.ts @@ -0,0 +1,40 @@ +import { loader } from 'fumadocs-core/source'; +import type { InferMetaType, InferPageType } from 'fumadocs-core/source'; +import { createMDXSource } from 'fumadocs-mdx'; +import { blog } from '.source'; + +export const source = loader({ + baseUrl: '/posts', + source: createMDXSource(blog), +}); +export const { getPage: getPost, getPages: getPosts, pageTree } = source; + +export type Post = ReturnType; + +const posts = getPosts(); + +export const getSortedByDatePosts = () => + posts.toSorted((a, b) => b.data.date.getTime() - a.data.date.getTime()); + +export const getTags = () => { + const tagSet = new Set(); + + for (const post of posts) { + if (post.data.tags) { + for (const tag of post.data.tags) { + tagSet.add(tag); + } + } + } + + return Array.from(tagSet).toSorted(); +}; + +export const getPostsByTag = (tag: string) => { + return posts + .filter((post) => post.data.tags?.includes(tag)) + .toSorted((a, b) => b.data.date.getTime() - a.data.date.getTime()); +}; + +export type Page = InferPageType; +export type Meta = InferMetaType; diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..9ad0df4 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/lib/validators/index.ts b/src/lib/validators/index.ts new file mode 100644 index 0000000..be59bc3 --- /dev/null +++ b/src/lib/validators/index.ts @@ -0,0 +1 @@ +export * from './newsletter'; diff --git a/src/lib/validators/newsletter.ts b/src/lib/validators/newsletter.ts new file mode 100644 index 0000000..9593fcf --- /dev/null +++ b/src/lib/validators/newsletter.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const NewsletterSchema = z.object({ + email: z.string().email(), +}); +export type Newsletter = z.infer; -- cgit v1.2.3