mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-10-30 03:16:03 -04:00
revert back to zod
This commit is contained in:
@@ -75,7 +75,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
<Footer className="my-6 w-full" />
|
||||
</div>
|
||||
|
||||
<Toaster position="bottom-center" />
|
||||
<Toaster position="bottom-center" hotkey={[]} />
|
||||
</ThemeProvider>
|
||||
|
||||
<Analytics />
|
||||
|
||||
10
components/ui/aspect-ratio.tsx
Normal file
10
components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const AspectRatio = ({ ...rest }: ComponentPropsWithoutRef<typeof AspectRatioPrimitive.Root>) => {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...rest} />;
|
||||
};
|
||||
|
||||
export default AspectRatio;
|
||||
35
components/ui/badge.tsx
Normal file
35
components/ui/badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Badge = ({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...rest
|
||||
}: ComponentPropsWithoutRef<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) => {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...rest} />;
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
53
components/ui/card.tsx
Normal file
53
components/ui/card.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const Card = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CardHeader = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CardTitle = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => {
|
||||
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...rest} />;
|
||||
};
|
||||
|
||||
const CardDescription = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => {
|
||||
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...rest} />;
|
||||
};
|
||||
|
||||
const CardAction = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CardContent = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => {
|
||||
return <div data-slot="card-content" className={cn("px-6", className)} {...rest} />;
|
||||
};
|
||||
|
||||
const CardFooter = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => {
|
||||
return <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...rest} />;
|
||||
};
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
47
components/ui/scroll-area.tsx
Normal file
47
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
const ScrollArea = ({ className, children, ...rest }: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) => {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...rest}>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ScrollBar = ({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...rest
|
||||
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) => {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
};
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
100
lib/env.ts
100
lib/env.ts
@@ -1,5 +1,5 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import * as v from "valibot";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
@@ -8,21 +8,21 @@ export const env = createEnv({
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/installation#set-environment-variables
|
||||
*/
|
||||
AUTH_SECRET: v.string(),
|
||||
AUTH_SECRET: z.string().min(1),
|
||||
|
||||
/**
|
||||
* Required. The client ID from the GitHub Developer Portal for this site's OAuth App.
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/authentication/github
|
||||
*/
|
||||
AUTH_GITHUB_CLIENT_ID: v.string(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().min(1),
|
||||
|
||||
/**
|
||||
* Required. A client secret from the GitHub Developer Portal for this site's OAuth App.
|
||||
*
|
||||
* @see https://www.better-auth.com/docs/authentication/github
|
||||
*/
|
||||
AUTH_GITHUB_CLIENT_SECRET: v.string(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().min(1),
|
||||
|
||||
/**
|
||||
* Required. Database connection string for a Postgres database. May be set automatically by Vercel's Neon
|
||||
@@ -30,7 +30,7 @@ export const env = createEnv({
|
||||
*
|
||||
* @see https://vercel.com/integrations/neon
|
||||
*/
|
||||
DATABASE_URL: v.pipe(v.string(), v.startsWith("postgres://")),
|
||||
DATABASE_URL: z.string().startsWith("postgres://"),
|
||||
|
||||
/**
|
||||
* Required. GitHub API token used for [/projects](../app/projects/page.tsx) grid. Only needs the `public_repo`
|
||||
@@ -38,7 +38,7 @@ export const env = createEnv({
|
||||
*
|
||||
* @see https://github.com/settings/tokens/new?scopes=public_repo
|
||||
*/
|
||||
GITHUB_TOKEN: v.optional(v.pipe(v.string(), v.startsWith("ghp_"))),
|
||||
GITHUB_TOKEN: z.string().startsWith("ghp_").optional(),
|
||||
|
||||
/**
|
||||
* Required. Uses Resend API to send contact form submissions via a [server action](../app/contact/action.ts). May
|
||||
@@ -47,7 +47,7 @@ export const env = createEnv({
|
||||
* @see https://resend.com/api-keys
|
||||
* @see https://vercel.com/integrations/resend
|
||||
*/
|
||||
RESEND_API_KEY: v.pipe(v.string(), v.startsWith("re_")),
|
||||
RESEND_API_KEY: z.string().startsWith("re_"),
|
||||
|
||||
/**
|
||||
* Optional, but will throw a warning if unset. Use an approved domain (or subdomain) on the Resend account to send
|
||||
@@ -56,17 +56,17 @@ export const env = createEnv({
|
||||
*
|
||||
* @see https://resend.com/domains
|
||||
*/
|
||||
RESEND_FROM_EMAIL: v.fallback(v.pipe(v.string(), v.email()), "onboarding@resend.dev"),
|
||||
RESEND_FROM_EMAIL: z.string().email().default("onboarding@resend.dev"),
|
||||
|
||||
/** Required. The destination email for contact form submissions. */
|
||||
RESEND_TO_EMAIL: v.pipe(v.string(), v.email()),
|
||||
RESEND_TO_EMAIL: z.string().email(),
|
||||
|
||||
/**
|
||||
* Required. Secret for Cloudflare `siteverify` API to validate a form's turnstile result on the backend.
|
||||
*
|
||||
* @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
*/
|
||||
TURNSTILE_SECRET_KEY: v.optional(v.string(), "1x0000000000000000000000000000000AA"),
|
||||
TURNSTILE_SECRET_KEY: z.string().default("1x0000000000000000000000000000000AA"),
|
||||
},
|
||||
client: {
|
||||
/**
|
||||
@@ -76,48 +76,52 @@ export const env = createEnv({
|
||||
*
|
||||
* @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#default-value
|
||||
*/
|
||||
NEXT_PUBLIC_BASE_URL: v.fallback(
|
||||
v.pipe(v.string(), v.url()),
|
||||
() =>
|
||||
// Vercel: https://vercel.com/docs/environment-variables/system-environment-variables
|
||||
(process.env.VERCEL
|
||||
? process.env.VERCEL_ENV === "production"
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: process.env.VERCEL_ENV === "preview"
|
||||
? `https://${process.env.VERCEL_BRANCH_URL}`
|
||||
: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: undefined
|
||||
: undefined) ||
|
||||
// Netlify: https://docs.netlify.com/configure-builds/environment-variables/#read-only-variables
|
||||
(process.env.NETLIFY
|
||||
? process.env.CONTEXT === "production"
|
||||
? `${process.env.URL}`
|
||||
: process.env.DEPLOY_PRIME_URL
|
||||
? `${process.env.DEPLOY_PRIME_URL}`
|
||||
: process.env.DEPLOY_URL
|
||||
? `${process.env.DEPLOY_URL}`
|
||||
: undefined
|
||||
: undefined) ||
|
||||
// next dev
|
||||
`http://localhost:${process.env.PORT || 3000}`
|
||||
),
|
||||
NEXT_PUBLIC_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default(
|
||||
((): string =>
|
||||
(process.env.VERCEL
|
||||
? process.env.VERCEL_ENV === "production"
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: process.env.VERCEL_ENV === "preview"
|
||||
? `https://${process.env.VERCEL_BRANCH_URL}`
|
||||
: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: undefined
|
||||
: undefined) ||
|
||||
(process.env.NETLIFY
|
||||
? process.env.CONTEXT === "production"
|
||||
? `${process.env.URL}`
|
||||
: process.env.DEPLOY_PRIME_URL
|
||||
? `${process.env.DEPLOY_PRIME_URL}`
|
||||
: process.env.DEPLOY_URL
|
||||
? `${process.env.DEPLOY_URL}`
|
||||
: undefined
|
||||
: undefined) ||
|
||||
`http://localhost:${process.env.PORT || 3000}`)()
|
||||
),
|
||||
|
||||
/**
|
||||
* Optional. Set this to override the best guess as to the environment the site is running in.
|
||||
*/
|
||||
NEXT_PUBLIC_ENV: v.fallback(v.picklist(["production", "development"]), () =>
|
||||
(process.env.VERCEL && process.env.VERCEL_ENV === "production") ||
|
||||
(process.env.NETLIFY && process.env.CONTEXT === "production")
|
||||
? "production"
|
||||
: "development"
|
||||
),
|
||||
NEXT_PUBLIC_ENV: z
|
||||
.enum(["production", "development"])
|
||||
.default(
|
||||
((): "production" | "development" =>
|
||||
(process.env.VERCEL && process.env.VERCEL_ENV === "production") ||
|
||||
(process.env.NETLIFY && process.env.CONTEXT === "production")
|
||||
? "production"
|
||||
: "development")()
|
||||
),
|
||||
|
||||
/** Required. GitHub repository for the site in the format of `{username}/{repo}`. */
|
||||
NEXT_PUBLIC_GITHUB_REPO: v.pipe(v.string(), v.includes("/")),
|
||||
NEXT_PUBLIC_GITHUB_REPO: z.string().refine((val) => val.includes("/"), {
|
||||
message: "Must be in the format {username}/{repo}",
|
||||
}),
|
||||
|
||||
/** Required. GitHub username of the author, used to generate [/projects](../app/projects/page.tsx). */
|
||||
NEXT_PUBLIC_GITHUB_USERNAME: v.string(),
|
||||
NEXT_PUBLIC_GITHUB_USERNAME: z.string().min(1),
|
||||
|
||||
/**
|
||||
* Optional. Sets an `Onion-Location` header in responses to advertise a URL for the same page but hosted on a
|
||||
@@ -126,14 +130,14 @@ export const env = createEnv({
|
||||
*
|
||||
* @see https://community.torproject.org/onion-services/advanced/onion-location/
|
||||
*/
|
||||
NEXT_PUBLIC_ONION_DOMAIN: v.optional(v.pipe(v.string(), v.endsWith(".onion"))),
|
||||
NEXT_PUBLIC_ONION_DOMAIN: z.string().endsWith(".onion").optional(),
|
||||
|
||||
/**
|
||||
* Optional. Locale code to define the site's language in ISO-639 format. Defaults to `en-US`.
|
||||
*
|
||||
* @see https://www.loc.gov/standards/iso639-2/php/code_list.php
|
||||
*/
|
||||
NEXT_PUBLIC_SITE_LOCALE: v.optional(v.string(), "en-US"),
|
||||
NEXT_PUBLIC_SITE_LOCALE: z.string().default("en-US"),
|
||||
|
||||
/**
|
||||
* Optional. Consistent timezone for the site. Doesn't really matter what it is, as long as it's the same everywhere
|
||||
@@ -141,7 +145,7 @@ export const env = createEnv({
|
||||
*
|
||||
* @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
|
||||
*/
|
||||
NEXT_PUBLIC_SITE_TZ: v.optional(v.string(), "America/New_York"),
|
||||
NEXT_PUBLIC_SITE_TZ: z.string().default("America/New_York"),
|
||||
|
||||
/**
|
||||
* Required. Site key must be prefixed with NEXT_PUBLIC_ since it is used to embed the captcha widget. Falls back to
|
||||
@@ -149,7 +153,7 @@ export const env = createEnv({
|
||||
*
|
||||
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
*/
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: v.optional(v.string(), "1x00000000000000000000AA"),
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().default("1x00000000000000000000AA"),
|
||||
},
|
||||
experimental__runtimeEnv: {
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
|
||||
@@ -2,36 +2,25 @@
|
||||
|
||||
import { env } from "@/lib/env";
|
||||
import { headers } from "next/headers";
|
||||
import * as v from "valibot";
|
||||
import { Resend } from "resend";
|
||||
import { z } from "zod";
|
||||
import siteConfig from "@/lib/config/site";
|
||||
|
||||
const ContactSchema = v.object({
|
||||
name: v.message(v.pipe(v.string(), v.trim(), v.nonEmpty()), "Your name is required."),
|
||||
email: v.message(v.pipe(v.string(), v.trim(), v.nonEmpty(), v.email()), "Your email address is required."),
|
||||
message: v.message(v.pipe(v.string(), v.trim(), v.minLength(15)), "Your message must be at least 15 characters."),
|
||||
"cf-turnstile-response": v.message(
|
||||
v.pipe(
|
||||
// token wasn't submitted at _all_, most likely a direct POST request by a spam bot
|
||||
v.string(),
|
||||
// form submitted properly but token was missing, might be a forgetful human
|
||||
v.nonEmpty(),
|
||||
// very rudimentary length check based on Cloudflare's docs
|
||||
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
v.maxLength(2048),
|
||||
v.readonly()
|
||||
),
|
||||
"Are you sure you're not a robot...? 🤖"
|
||||
),
|
||||
});
|
||||
const ContactSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, { message: "Your name is required." }),
|
||||
email: z.string().email({ message: "Your email address is required." }),
|
||||
message: z.string().trim().min(15, { message: "Your message must be at least 15 characters." }),
|
||||
"cf-turnstile-response": z.string().min(1, { message: "Are you sure you're not a robot...? 🤖" }),
|
||||
})
|
||||
.readonly();
|
||||
|
||||
export type ContactInput = v.InferInput<typeof ContactSchema>;
|
||||
export type ContactInput = z.infer<typeof ContactSchema>;
|
||||
|
||||
export type ContactState = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
errors?: v.FlatErrors<typeof ContactSchema>["nested"];
|
||||
errors?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export const send = async (state: ContactState, payload: FormData): Promise<ContactState> => {
|
||||
@@ -39,13 +28,13 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
|
||||
console.debug("[server/resend] received payload:", payload);
|
||||
|
||||
try {
|
||||
const data = v.safeParse(ContactSchema, Object.fromEntries(payload));
|
||||
const data = ContactSchema.safeParse(Object.fromEntries(payload));
|
||||
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please make sure all fields are filled in.",
|
||||
errors: v.flatten(data.issues).nested,
|
||||
errors: data.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,7 +50,7 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
secret: env.TURNSTILE_SECRET_KEY,
|
||||
response: data.output["cf-turnstile-response"],
|
||||
response: data.data["cf-turnstile-response"],
|
||||
remoteip,
|
||||
}),
|
||||
cache: "no-store",
|
||||
@@ -88,11 +77,11 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
|
||||
// send email
|
||||
const resend = new Resend(env.RESEND_API_KEY);
|
||||
await resend.emails.send({
|
||||
from: `${data.output.name} <${env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
|
||||
replyTo: `${data.output.name} <${data.output.email}>`,
|
||||
from: `${data.data.name} <${env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
|
||||
replyTo: `${data.data.name} <${data.data.email}>`,
|
||||
to: [env.RESEND_TO_EMAIL],
|
||||
subject: `[${siteConfig.name}] Contact Form Submission`,
|
||||
text: data.output.message,
|
||||
text: data.data.message,
|
||||
});
|
||||
|
||||
return { success: true, message: "Thanks! You should hear from me soon." };
|
||||
|
||||
@@ -8,16 +8,16 @@ import { page } from "@/lib/db/schema";
|
||||
export const incrementViews = async (slug: string): Promise<number> => {
|
||||
try {
|
||||
// First, try to find the existing record
|
||||
const existingHit = await db.select().from(page).where(eq(page.slug, slug)).limit(1);
|
||||
const existingPage = await db.select().from(page).where(eq(page.slug, slug)).limit(1);
|
||||
|
||||
if (existingHit.length === 0) {
|
||||
if (existingPage.length === 0) {
|
||||
// Create new record if it doesn't exist
|
||||
await db.insert(page).values({ slug, views: 1 }).execute();
|
||||
|
||||
return 1; // New record starts with 1 hit
|
||||
} else {
|
||||
// Calculate new hit count
|
||||
const newViewCount = existingHit[0].views + 1;
|
||||
const newViewCount = existingPage[0].views + 1;
|
||||
|
||||
// Update existing record by incrementing hits
|
||||
await db.update(page).set({ views: newViewCount }).where(eq(page.slug, slug)).execute();
|
||||
|
||||
32
package.json
32
package.json
@@ -24,22 +24,24 @@
|
||||
"@mdx-js/loader": "^3.1.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@neondatabase/serverless": "^1.0.0",
|
||||
"@next/bundle-analyzer": "15.4.0-canary.34",
|
||||
"@next/mdx": "15.4.0-canary.34",
|
||||
"@next/bundle-analyzer": "15.4.0-canary.38",
|
||||
"@next/mdx": "15.4.0-canary.38",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/graphql-schema": "^15.26.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.13",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.9",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.13",
|
||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||
"@radix-ui/react-separator": "^1.1.6",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-toast": "^1.2.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@t3-oss/env-nextjs": "^0.13.4",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"better-auth": "^1.2.7",
|
||||
"better-auth": "1.2.9-beta.1",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -50,8 +52,8 @@
|
||||
"feed": "^5.0.1",
|
||||
"geist": "^1.4.2",
|
||||
"html-entities": "^2.6.0",
|
||||
"lucide-react": "0.510.0",
|
||||
"next": "15.4.0-canary.34",
|
||||
"lucide-react": "0.511.0",
|
||||
"next": "15.4.0-canary.38",
|
||||
"react": "19.1.0",
|
||||
"react-activity-calendar": "^2.7.11",
|
||||
"react-countup": "^6.5.3",
|
||||
@@ -81,37 +83,37 @@
|
||||
"remark-strip-mdx-imports-exports": "^1.0.1",
|
||||
"resend": "^4.5.1",
|
||||
"server-only": "0.0.1",
|
||||
"shiki": "^3.4.1",
|
||||
"shiki": "^3.4.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"unified": "^11.0.5",
|
||||
"valibot": "^1.1.0"
|
||||
"zod": "3.25.0-beta.20250516T005923"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@jakejarvis/eslint-config": "^4.0.7",
|
||||
"@tailwindcss/postcss": "^4.1.6",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-af1b7da-20250417",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-config-next": "15.4.0-canary.34",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-next": "15.4.0-canary.38",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-css-modules": "^2.12.0",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-mdx": "^3.4.1",
|
||||
"eslint-plugin-mdx": "^3.4.2",
|
||||
"eslint-plugin-prettier": "^5.4.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-af1b7da-20250417",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.0.0",
|
||||
|
||||
1208
pnpm-lock.yaml
generated
1208
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user