1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 19:35:27 -04:00

refactor: eslint/prettier ➡️ biome

This commit is contained in:
2026-02-19 14:02:03 -05:00
parent 4858c8928c
commit c30197ccc5
115 changed files with 2584 additions and 5683 deletions
-4
View File
@@ -1,4 +0,0 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
/notes/
/public/
+3 -8
View File
@@ -5,9 +5,7 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": { "settings": {
"editor.rulers": [ "editor.rulers": [120],
120
],
"extensions.ignoreRecommendations": true, "extensions.ignoreRecommendations": true,
"git.allowForcePush": true, "git.allowForcePush": true,
"git.autofetch": true, "git.autofetch": true,
@@ -24,9 +22,8 @@
"typescript.updateImportsOnFileMove.enabled": "always" "typescript.updateImportsOnFileMove.enabled": "always"
}, },
"extensions": [ "extensions": [
"biomejs.biome",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"unifiedjs.vscode-mdx" "unifiedjs.vscode-mdx"
] ]
} }
@@ -34,9 +31,7 @@
"tasks": { "tasks": {
"build": "pnpm install --frozen-lockfile && pnpm build" "build": "pnpm install --frozen-lockfile && pnpm build"
}, },
"forwardPorts": [ "forwardPorts": [3000],
3000
],
"containerEnv": { "containerEnv": {
"CHECKPOINT_DISABLE": "1", "CHECKPOINT_DISABLE": "1",
"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0", "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0",
-13
View File
@@ -1,13 +0,0 @@
# http://editorconfig.org
# this file is the top-most editorconfig file
root = true
# all files
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
-1
View File
@@ -1 +0,0 @@
./node_modules/.bin/lint-staged
-14
View File
@@ -1,14 +0,0 @@
# pnpm
node_modules/
pnpm-lock.yaml
.pnpm-store/
# next.js
.next/
.vercel/
# other
lib/db/migrations/
public/
.devcontainer/devcontainer.json
.vscode/
-14
View File
@@ -1,14 +0,0 @@
/** @type {import("prettier").Config} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
tailwindFunctions: ["cn", "clsx", "cva", "twMerge"],
jsxSingleQuote: false,
printWidth: 120,
quoteProps: "as-needed",
singleQuote: false,
tabWidth: 2,
trailingComma: "es5",
useTabs: false,
};
export default config;
+1 -2
View File
@@ -1,8 +1,7 @@
{ {
"recommendations": [ "recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"unifiedjs.vscode-mdx" "unifiedjs.vscode-mdx"
] ]
} }
+31 -11
View File
@@ -1,16 +1,36 @@
{ {
"editor.tabSize": 2, "typescript.tsdk": "node_modules/typescript/lib",
"editor.rulers": [ "editor.defaultFormatter": "biomejs.biome",
120 "editor.formatOnSave": true,
], "editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"emmet.showExpandedAbbreviation": "never",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"files.associations": { "files.associations": {
"*.css": "tailwindcss", "*.css": "tailwindcss",
"*.mdx": "markdown" "*.mdx": "markdown"
}, }
"javascript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.surveys.enabled": false,
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.tsserver.log": "off",
"typescript.updateImportsOnFileMove.enabled": "always"
} }
+3 -12
View File
@@ -18,14 +18,14 @@ No test suite exists. Validate changes via `pnpm typecheck` and `pnpm lint`.
## Code Style ## Code Style
### Formatting (Prettier) ### Formatting (Biome)
- **Line width:** 120 characters - **Line width:** 80 characters
- **Indentation:** 2 spaces (no tabs) - **Indentation:** 2 spaces (no tabs)
- **Quotes:** Double quotes, no JSX single quotes - **Quotes:** Double quotes, no JSX single quotes
- **Trailing commas:** ES5 style - **Trailing commas:** ES5 style
- **Semicolons:** Required - **Semicolons:** Required
- **Tailwind:** Classes auto-sorted via `prettier-plugin-tailwindcss` - **Tailwind:** Classes auto-sorted via `biome --write --unsafe`
### Import Organization ### Import Organization
@@ -126,15 +126,6 @@ export const getData = async (slug: string) => {
}; };
``` ```
### Environment Variables
Type-checked in `lib/env.ts` using `@t3-oss/env-nextjs`. Never access `process.env` directly:
```typescript
import { env } from "@/lib/env";
const apiKey = env.RESEND_API_KEY;
```
## Project Structure ## Project Structure
``` ```
+6 -9
View File
@@ -6,27 +6,24 @@
[![GitHub repo size](https://img.shields.io/github/repo-size/jakejarvis/jarv.is?color=009cdf&label=repo%20size&logo=git&logoColor=white)](https://github.com/jakejarvis/jarv.is) [![GitHub repo size](https://img.shields.io/github/repo-size/jakejarvis/jarv.is?color=009cdf&label=repo%20size&logo=git&logoColor=white)](https://github.com/jakejarvis/jarv.is)
[![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fjarv.is%2Fapi%2Fhits&query=%24.total.hits&logo=googleanalytics&logoColor=white&label=hits&color=salmon&cacheSeconds=1800)](https://jarv.is/api/hits) [![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fjarv.is%2Fapi%2Fhits&query=%24.total.hits&logo=googleanalytics&logoColor=white&label=hits&color=salmon&cacheSeconds=1800)](https://jarv.is/api/hits)
My humble abode on the World Wide Web, created and deployed using [Next.js](https://nextjs.org/), [Tailwind CSS](https://github.com/user-attachments/assets/dfe99976-c73d-46f1-8a50-f26338463ad8), [Planetscale Postgres](https://planetscale.com/), [Drizzle](https://orm.drizzle.team/), [Better Auth](https://www.better-auth.com/), [and more](https://jarv.is/humans.txt). My humble abode on the World Wide Web, created with [Next.js](https://nextjs.org/), [Tailwind CSS](https://github.com/user-attachments/assets/dfe99976-c73d-46f1-8a50-f26338463ad8), [Planetscale Postgres](https://planetscale.com/), [Drizzle](https://orm.drizzle.team/), [Better Auth](https://www.better-auth.com/), [and more](https://jarv.is/humans.txt).
## 🕹️ Getting Started ## Development
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/jakejarvis/jarv.is) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/jakejarvis/jarv.is)
I highly recommend spinning up a [Codespace](https://github.com/features/codespaces) with the button above to start inside of a preconfigured and tested environment. But you can also clone this repository locally, run `pnpm install` to pull down the necessary dependencies and `pnpm dev` to start the local server, and then open [localhost:3000](http://localhost:3000/) in a browser. Pages will live-refresh when source files are changed. I highly recommend spinning up a [Codespace](https://github.com/features/codespaces) with the button above to start inside of a preconfigured and tested environment.
**Be sure to populate the required environment variables!** Refer to [`lib/env.ts`](lib/env.ts), which documents (and strictly [type-checks](https://env.t3.gg/docs/introduction)) these variables. The included [`.env.example`](.env.example) file should be copied and used as a template for a new local `.env` file, which the local `next dev` server will then ingest. **Be sure to populate the required environment variables!**
> ⚠️ **Currently, there are a few assumptions sprinkled throughout the code that this repo will be deployed to [Vercel](https://nextjs.org/docs/app/building-your-application/deploying#managed-nextjs-with-vercel) and _only_ Vercel.** I'll correct this soon™ now that some escape hatches (namely [OpenNext](https://opennext.js.org/)) actually exist... ## Related
## 🌎 Related
- [💻 /uses](https://jarv.is/uses) Things and stuff I use. - [💻 /uses](https://jarv.is/uses) Things and stuff I use.
- [🕰️ /previously](https://jarv.is/previously) An embarrassing trip down this site's memory lane. - [🕰️ /previously](https://jarv.is/previously) An embarrassing trip down this site's memory lane.
- Visit [/y2k](https://jarv.is/y2k) if you want to experience the _fully_ immersive time machine, but don't say I didn't warn you...
- [🧅 Tor (.onion) mirror](http://jarvis2i2vp4j4tbxjogsnqdemnte5xhzyi7hziiyzxwge3hzmh57zad.onion/) For an excessive level of privacy and security. - [🧅 Tor (.onion) mirror](http://jarvis2i2vp4j4tbxjogsnqdemnte5xhzyi7hziiyzxwge3hzmh57zad.onion/) For an excessive level of privacy and security.
- [🧮 jakejarvis/website-stats](https://github.com/jakejarvis/website-stats) Daily raw snapshots of the [hit counter](app/api/hits/route.ts) database. - [🧮 jakejarvis/website-stats](https://github.com/jakejarvis/website-stats) Daily raw snapshots of the [hit counter](app/api/hits/route.ts) database.
## 📜 License ## License
Site content is licensed under the [CC-BY-4.0 license](LICENSE), which means that you can copy, redistribute, remix, transform, and build upon the content for any purpose as long as you give appropriate credit. Site content is licensed under the [CC-BY-4.0 license](LICENSE), which means that you can copy, redistribute, remix, transform, and build upon the content for any purpose as long as you give appropriate credit.
+1 -3
View File
@@ -1,13 +1,11 @@
import { Analytics as VercelAnalytics } from "@vercel/analytics/next"; import { Analytics as VercelAnalytics } from "@vercel/analytics/next";
import { SpeedInsights as VercelSpeedInsights } from "@vercel/speed-insights/next"; import { SpeedInsights as VercelSpeedInsights } from "@vercel/speed-insights/next";
const Analytics = () => { const Analytics = () => (
return (
<> <>
<VercelAnalytics /> <VercelAnalytics />
<VercelSpeedInsights /> <VercelSpeedInsights />
</> </>
); );
};
export { Analytics }; export { Analytics };
+1 -1
View File
@@ -1,4 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js"; import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { POST, GET } = toNextJsHandler(auth); export const { POST, GET } = toNextJsHandler(auth);
+3 -5
View File
@@ -1,9 +1,9 @@
import { env } from "@/lib/env";
import { JsonLd } from "react-schemaorg"; import { JsonLd } from "react-schemaorg";
import type { VideoObject } from "schema-dts";
import { PageTitle } from "@/components/layout/page-title"; import { PageTitle } from "@/components/layout/page-title";
import { Video } from "@/components/video"; import { Video } from "@/components/video";
import { env } from "@/lib/env";
import { createMetadata } from "@/lib/metadata"; import { createMetadata } from "@/lib/metadata";
import type { VideoObject } from "schema-dts";
import thumbnail from "./thumbnail.png"; import thumbnail from "./thumbnail.png";
@@ -21,8 +21,7 @@ export const metadata = createMetadata({
}, },
}); });
const Page = () => { const Page = () => (
return (
<> <>
<JsonLd<VideoObject> <JsonLd<VideoObject>
item={{ item={{
@@ -50,6 +49,5 @@ const Page = () => {
/> />
</> </>
); );
};
export default Page; export default Page;
+18 -10
View File
@@ -1,28 +1,37 @@
import { PageTitle } from "@/components/layout/page-title";
import { ContactForm } from "@/components/contact-form"; import { ContactForm } from "@/components/contact-form";
import { PageTitle } from "@/components/layout/page-title";
import { createMetadata } from "@/lib/metadata"; import { createMetadata } from "@/lib/metadata";
export const metadata = createMetadata({ export const metadata = createMetadata({
title: "Contact Me", title: "Contact Me",
description: "Fill out this quick form and I'll get back to you as soon as I can.", description:
"Fill out this quick form and I'll get back to you as soon as I can.",
canonical: "/contact", canonical: "/contact",
}); });
const Page = () => { const Page = () => (
return (
<> <>
<PageTitle canonical="/contact">Contact</PageTitle> <PageTitle canonical="/contact">Contact</PageTitle>
<div className="mx-auto w-full max-w-2xl"> <div className="mx-auto w-full max-w-2xl">
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none"> <div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
<p> <p>
Fill out this quick form and I&rsquo;ll get back to you as soon as I can! You can also{" "} Fill out this quick form and I&rsquo;ll get back to you as soon as I
<a href="mailto:jake@jarv.is">email me directly</a> or send me a direct message on{" "} can! You can also <a href="mailto:jake@jarv.is">email me directly</a>{" "}
<a href="https://bsky.app/profile/jarv.is" target="_blank" rel="noopener noreferrer"> or send me a direct message on{" "}
<a
href="https://bsky.app/profile/jarv.is"
target="_blank"
rel="noopener noreferrer"
>
Bluesky Bluesky
</a>{" "} </a>{" "}
or{" "} or{" "}
<a href="https://fediverse.jarv.is/@jake" target="_blank" rel="noopener noreferrer"> <a
href="https://fediverse.jarv.is/@jake"
target="_blank"
rel="noopener noreferrer"
>
Mastodon Mastodon
</a> </a>
. .
@@ -34,7 +43,7 @@ const Page = () => {
target="_blank" target="_blank"
rel="noopener" rel="noopener"
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39" title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
className="bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium tracking-wider [word-spacing:-0.25em]" className="relative rounded-sm bg-muted px-[0.3rem] py-[0.2rem] font-medium font-mono text-sm tracking-wider [word-spacing:-0.25em]"
> >
2B0C 9CF2 51E6 9A39 2B0C 9CF2 51E6 9A39
</a> </a>
@@ -46,6 +55,5 @@ const Page = () => {
</div> </div>
</> </>
); );
};
export default Page; export default Page;
+24 -10
View File
@@ -1,15 +1,16 @@
import { env } from "@/lib/env";
import { JsonLd } from "react-schemaorg"; import { JsonLd } from "react-schemaorg";
import type { VideoObject } from "schema-dts";
import { PageTitle } from "@/components/layout/page-title"; import { PageTitle } from "@/components/layout/page-title";
import { Video } from "@/components/video"; import { Video } from "@/components/video";
import { env } from "@/lib/env";
import { createMetadata } from "@/lib/metadata"; import { createMetadata } from "@/lib/metadata";
import type { VideoObject } from "schema-dts";
import thumbnail from "./thumbnail.png"; import thumbnail from "./thumbnail.png";
export const metadata = createMetadata({ export const metadata = createMetadata({
title: "My Brief Apperance in Hillary Clinton's DNC Video", title: "My Brief Apperance in Hillary Clinton's DNC Video",
description: "My brief apperance in one of Hillary Clinton's 2016 DNC convention videos on substance abuse.", description:
"My brief apperance in one of Hillary Clinton's 2016 DNC convention videos on substance abuse.",
canonical: "/hillary", canonical: "/hillary",
openGraph: { openGraph: {
videos: [ videos: [
@@ -21,8 +22,7 @@ export const metadata = createMetadata({
}, },
}); });
const Page = () => { const Page = () => (
return (
<> <>
<JsonLd<VideoObject> <JsonLd<VideoObject>
item={{ item={{
@@ -50,23 +50,37 @@ const Page = () => {
poster={thumbnail.src} poster={thumbnail.src}
/> />
<p className="text-muted-foreground mx-4 mt-5 mb-0 text-center text-sm leading-relaxed"> <p className="mx-4 mt-5 mb-0 text-center text-muted-foreground text-sm leading-relaxed">
Video is property of{" "} Video is property of{" "}
<a href="https://www.hillaryclinton.com/" target="_blank" rel="noopener noreferrer" className="font-bold"> <a
href="https://www.hillaryclinton.com/"
target="_blank"
rel="noopener noreferrer"
className="font-bold"
>
Hillary for America Hillary for America
</a> </a>
, the{" "} , the{" "}
<a href="https://democrats.org/" target="_blank" rel="noopener noreferrer" className="font-bold"> <a
href="https://democrats.org/"
target="_blank"
rel="noopener noreferrer"
className="font-bold"
>
Democratic National Committee Democratic National Committee
</a> </a>
, and{" "} , and{" "}
<a href="https://cnnpressroom.blogs.cnn.com/" target="_blank" rel="noopener noreferrer" className="font-bold"> <a
href="https://cnnpressroom.blogs.cnn.com/"
target="_blank"
rel="noopener noreferrer"
className="font-bold"
>
CNN / WarnerMedia CNN / WarnerMedia
</a> </a>
. &copy; 2016. . &copy; 2016.
</p> </p>
</> </>
); );
};
export default Page; export default Page;
+12 -14
View File
@@ -1,23 +1,22 @@
import { ViewTransition } from "react"; import { ViewTransition } from "react";
import { env } from "@/lib/env";
import { JsonLd } from "react-schemaorg"; import { JsonLd } from "react-schemaorg";
import { Providers } from "@/components/providers";
import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
import { Toaster } from "@/components/ui/sonner";
import { Analytics } from "@/app/analytics";
import { defaultMetadata } from "@/lib/metadata";
import { Inter, JetBrainsMono } from "@/lib/fonts";
import siteConfig from "@/lib/config/site";
import authorConfig from "@/lib/config/author";
import type { Person, WebSite } from "schema-dts"; import type { Person, WebSite } from "schema-dts";
import { Analytics } from "@/app/analytics";
import { Footer } from "@/components/layout/footer";
import { Header } from "@/components/layout/header";
import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/sonner";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
import { env } from "@/lib/env";
import { Inter, JetBrainsMono } from "@/lib/fonts";
import { defaultMetadata } from "@/lib/metadata";
import "./globals.css"; import "./globals.css";
export const metadata = defaultMetadata; export const metadata = defaultMetadata;
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => (
return (
<html <html
lang={env.NEXT_PUBLIC_SITE_LOCALE} lang={env.NEXT_PUBLIC_SITE_LOCALE}
className={`${Inter.variable} ${JetBrainsMono.variable}`} className={`${Inter.variable} ${JetBrainsMono.variable}`}
@@ -61,7 +60,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
/> />
</head> </head>
<body className="bg-background text-foreground font-sans antialiased"> <body className="bg-background font-sans text-foreground antialiased">
<Providers> <Providers>
<Header /> <Header />
<div className="mx-auto mt-4 w-full max-w-4xl px-5"> <div className="mx-auto mt-4 w-full max-w-4xl px-5">
@@ -77,6 +76,5 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
</body> </body>
</html> </html>
); );
};
export default RootLayout; export default RootLayout;
+12 -8
View File
@@ -1,9 +1,9 @@
import { env } from "@/lib/env";
import { JsonLd } from "react-schemaorg"; import { JsonLd } from "react-schemaorg";
import type { VideoObject } from "schema-dts";
import { PageTitle } from "@/components/layout/page-title"; import { PageTitle } from "@/components/layout/page-title";
import { Video } from "@/components/video"; import { Video } from "@/components/video";
import { env } from "@/lib/env";
import { createMetadata } from "@/lib/metadata"; import { createMetadata } from "@/lib/metadata";
import type { VideoObject } from "schema-dts";
import thumbnail from "./thumbnail.png"; import thumbnail from "./thumbnail.png";
@@ -21,8 +21,7 @@ export const metadata = createMetadata({
}, },
}); });
const Page = () => { const Page = () => (
return (
<> <>
<JsonLd<VideoObject> <JsonLd<VideoObject>
item={{ item={{
@@ -30,7 +29,8 @@ const Page = () => {
"@type": "VideoObject", "@type": "VideoObject",
name: metadata.title as string, name: metadata.title as string,
description: metadata.description as string, description: metadata.description as string,
contentUrl: "https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo-uoCXHS9gViyRnQhr8CEGXFvj4VGh5Y.webm", contentUrl:
"https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/leo-uoCXHS9gViyRnQhr8CEGXFvj4VGh5Y.webm",
thumbnailUrl: `${env.NEXT_PUBLIC_BASE_URL}${thumbnail.src}`, thumbnailUrl: `${env.NEXT_PUBLIC_BASE_URL}${thumbnail.src}`,
embedUrl: `${env.NEXT_PUBLIC_BASE_URL}/leo`, embedUrl: `${env.NEXT_PUBLIC_BASE_URL}/leo`,
uploadDate: "2007-05-10T00:00:00Z", uploadDate: "2007-05-10T00:00:00Z",
@@ -49,7 +49,7 @@ const Page = () => {
poster={thumbnail.src} poster={thumbnail.src}
/> />
<p className="text-muted-foreground mx-4 mt-5 mb-0 text-center text-sm leading-relaxed"> <p className="mx-4 mt-5 mb-0 text-center text-muted-foreground text-sm leading-relaxed">
Video is property of{" "} Video is property of{" "}
<a <a
href="https://web.archive.org/web/20070511004304/www.g4techtv.ca" href="https://web.archive.org/web/20070511004304/www.g4techtv.ca"
@@ -60,13 +60,17 @@ const Page = () => {
G4techTV Canada G4techTV Canada
</a>{" "} </a>{" "}
&amp;{" "} &amp;{" "}
<a href="https://leo.fm/" target="_blank" rel="noopener noreferrer" className="font-bold"> <a
href="https://leo.fm/"
target="_blank"
rel="noopener noreferrer"
className="font-bold"
>
Leo Laporte Leo Laporte
</a> </a>
. &copy; 2007 G4 Media, Inc. . &copy; 2007 G4 Media, Inc.
</p> </p>
</> </>
); );
};
export default Page; export default Page;
+2 -2
View File
@@ -1,6 +1,6 @@
import { env } from "@/lib/env";
import siteConfig from "@/lib/config/site";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import siteConfig from "@/lib/config/site";
import { env } from "@/lib/env";
const manifest = (): MetadataRoute.Manifest => { const manifest = (): MetadataRoute.Manifest => {
return { return {
+6 -6
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Video } from "@/components/video"; import { Video } from "@/components/video";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Page Not Found", title: "Page Not Found",
@@ -12,8 +12,7 @@ export const metadata: Metadata = {
}, },
}; };
const Page = () => { const Page = () => (
return (
<> <>
<Video <Video
src="https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/not-found-SAtLyNyc7gVhveYxr6o1ITd9CSXo5X.mp4" src="https://ijyxfbpcm3itvdly.public.blob.vercel-storage.com/not-found-SAtLyNyc7gVhveYxr6o1ITd9CSXo5X.mp4"
@@ -22,7 +21,9 @@ const Page = () => {
/> />
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<h1 className="my-2 text-2xl font-semibold md:text-3xl">Page Not Found</h1> <h1 className="my-2 font-semibold text-2xl md:text-3xl">
Page Not Found
</h1>
<Button className="mt-4 mb-0 text-[15px] leading-none" size="lg" asChild> <Button className="mt-4 mb-0 text-[15px] leading-none" size="lg" asChild>
<Link href="/">Go home?</Link> <Link href="/">Go home?</Link>
@@ -30,6 +31,5 @@ const Page = () => {
</div> </div>
</> </>
); );
};
export default Page; export default Page;
+40 -16
View File
@@ -1,11 +1,11 @@
import { env } from "@/lib/env"; import fs from "node:fs";
import path from "path"; import path from "node:path";
import fs from "fs";
import { ImageResponse } from "next/og";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getSlugs, getFrontMatter, POSTS_DIR } from "@/lib/posts"; import { ImageResponse } from "next/og";
import siteConfig from "@/lib/config/site"; import siteConfig from "@/lib/config/site";
import { env } from "@/lib/env";
import { loadGoogleFont } from "@/lib/og-utils"; import { loadGoogleFont } from "@/lib/og-utils";
import { getFrontMatter, getSlugs, POSTS_DIR } from "@/lib/posts";
export const contentType = "image/png"; export const contentType = "image/png";
export const size = { export const size = {
@@ -31,7 +31,9 @@ const getLocalImage = async (src: string): Promise<ArrayBuffer | string> => {
try { try {
if (!fs.existsSync(imagePath)) { if (!fs.existsSync(imagePath)) {
console.error(`[/notes/[slug]/opengraph-image] couldn't find an image file located at "${imagePath}"`); console.error(
`[/notes/[slug]/opengraph-image] couldn't find an image file located at "${imagePath}"`,
);
// return a 1x1 transparent gif if the image doesn't exist instead of crashing // return a 1x1 transparent gif if the image doesn't exist instead of crashing
return NO_IMAGE; return NO_IMAGE;
@@ -40,27 +42,40 @@ const getLocalImage = async (src: string): Promise<ArrayBuffer | string> => {
// return the raw image data as a buffer // return the raw image data as a buffer
return Uint8Array.from(await fs.promises.readFile(imagePath)).buffer; return Uint8Array.from(await fs.promises.readFile(imagePath)).buffer;
} catch (error) { } catch (error) {
console.error(`[/notes/[slug]/opengraph-image] found "${imagePath}" but couldn't read it:`, error); console.error(
`[/notes/[slug]/opengraph-image] found "${imagePath}" but couldn't read it:`,
error,
);
// fail silently and return a 1x1 transparent gif instead of crashing // fail silently and return a 1x1 transparent gif instead of crashing
return NO_IMAGE; return NO_IMAGE;
} }
}; };
const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> }) => { const OpenGraphImage = async ({
params,
}: {
params: Promise<{ slug: string }>;
}) => {
try { try {
const { slug } = await params; const { slug } = await params;
// get the post's title and image filename from its frontmatter // get the post's title and image filename from its frontmatter
const frontmatter = await getFrontMatter(slug); const frontmatter = await getFrontMatter(slug);
if (!frontmatter) notFound();
// IMPORTANT: include these exact paths in next.config.ts under "outputFileTracingIncludes" // IMPORTANT: include these exact paths in next.config.ts under "outputFileTracingIncludes"
const [postImg, avatarImg] = await Promise.all([ const [postImg, avatarImg] = await Promise.all([
frontmatter!.image ? getLocalImage(`${POSTS_DIR}/${slug}/${frontmatter!.image}`) : null, frontmatter.image
? getLocalImage(`${POSTS_DIR}/${slug}/${frontmatter.image}`)
: null,
getLocalImage("app/avatar.jpg"), getLocalImage("app/avatar.jpg"),
]); ]);
const [fontRegular, fontSemibold] = await Promise.all([loadGoogleFont("Inter", 400), loadGoogleFont("Inter", 600)]); const [fontRegular, fontSemibold] = await Promise.all([
loadGoogleFont("Inter", 400),
loadGoogleFont("Inter", 600),
]);
// template is HEAVILY inspired by https://og-new.clerkstage.dev/ // template is HEAVILY inspired by https://og-new.clerkstage.dev/
return new ImageResponse( return new ImageResponse(
@@ -69,7 +84,8 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
...size, ...size,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
background: "linear-gradient(to top right, rgb(134, 239, 172), rgb(59, 130, 246), rgb(147, 51, 234))", background:
"linear-gradient(to top right, rgb(134, 239, 172), rgb(59, 130, 246), rgb(147, 51, 234))",
}} }}
> >
<div <div
@@ -123,6 +139,7 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
}} }}
> >
{avatarImg && ( {avatarImg && (
// biome-ignore lint/performance/noImgElement: Satori/ImageResponse requires raw <img> tags
<img <img
// @ts-expect-error // @ts-expect-error
src={avatarImg} src={avatarImg}
@@ -158,7 +175,7 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
lineHeight: "1.2", lineHeight: "1.2",
}} }}
> >
{frontmatter!.title} {frontmatter.title}
</div> </div>
<div <div
@@ -196,11 +213,14 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
lineHeight: "1.2", lineHeight: "1.2",
}} }}
> >
{new Date(frontmatter!.date).toLocaleDateString(env.NEXT_PUBLIC_SITE_LOCALE, { {new Date(frontmatter.date).toLocaleDateString(
env.NEXT_PUBLIC_SITE_LOCALE,
{
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
})} },
)}
</div> </div>
</div> </div>
@@ -212,6 +232,7 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
flexGrow: 0, flexGrow: 0,
}} }}
> >
{/* biome-ignore lint/performance/noImgElement: Satori/ImageResponse requires raw <img> tags */}
<img <img
// @ts-expect-error // @ts-expect-error
src={postImg} src={postImg}
@@ -242,10 +263,13 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
weight: 600, weight: 600,
}, },
], ],
} },
); );
} catch (error) { } catch (error) {
console.error("[/notes/[slug]/opengraph-image] error generating open graph image:", error); console.error(
"[/notes/[slug]/opengraph-image] error generating open graph image:",
error,
);
notFound(); notFound();
} }
}; };
+90 -51
View File
@@ -1,19 +1,26 @@
import { env } from "@/lib/env"; import {
import { Suspense } from "react"; CalendarDaysIcon,
EyeIcon,
MessagesSquareIcon,
SquarePenIcon,
TagIcon,
} from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { JsonLd } from "react-schemaorg"; import { JsonLd } from "react-schemaorg";
import { CalendarDaysIcon, TagIcon, SquarePenIcon, EyeIcon, MessagesSquareIcon } from "lucide-react"; import type { BlogPosting } from "schema-dts";
import { ViewCounter } from "@/components/view-counter";
import { CommentCount } from "@/components/comment-count"; import { CommentCount } from "@/components/comment-count";
import { Comments } from "@/components/comments/comments"; import { Comments } from "@/components/comments/comments";
import { CommentsSkeleton } from "@/components/comments/comments-skeleton"; import { CommentsSkeleton } from "@/components/comments/comments-skeleton";
import { getSlugs, getFrontMatter, POSTS_DIR } from "@/lib/posts"; import { ViewCounter } from "@/components/view-counter";
import { createMetadata } from "@/lib/metadata";
import siteConfig from "@/lib/config/site";
import authorConfig from "@/lib/config/author"; import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
import { env } from "@/lib/env";
import { createMetadata } from "@/lib/metadata";
import { getFrontMatter, getSlugs, POSTS_DIR } from "@/lib/posts";
import { size as ogImageSize } from "./opengraph-image"; import { size as ogImageSize } from "./opengraph-image";
import type { Metadata } from "next";
import type { BlogPosting } from "schema-dts";
export const generateStaticParams = async () => { export const generateStaticParams = async () => {
const slugs = await getSlugs(); const slugs = await getSlugs();
@@ -24,20 +31,24 @@ export const generateStaticParams = async () => {
})); }));
}; };
export const generateMetadata = async ({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> => { export const generateMetadata = async ({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> => {
const { slug } = await params; const { slug } = await params;
const frontmatter = await getFrontMatter(slug); const frontmatter = await getFrontMatter(slug);
return createMetadata({ return createMetadata({
title: frontmatter!.title, title: frontmatter?.title,
description: frontmatter!.description, description: frontmatter?.description,
canonical: `/${POSTS_DIR}/${slug}`, canonical: `/${POSTS_DIR}/${slug}`,
openGraph: { openGraph: {
type: "article", type: "article",
authors: [authorConfig.name], authors: [authorConfig.name],
tags: frontmatter!.tags, tags: frontmatter?.tags,
publishedTime: frontmatter!.date, publishedTime: frontmatter?.date,
modifiedTime: frontmatter!.date, modifiedTime: frontmatter?.date,
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
@@ -48,7 +59,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ slug: str
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => { const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { slug } = await params; const { slug } = await params;
const frontmatter = await getFrontMatter(slug); const frontmatter = await getFrontMatter(slug);
const d = new Date(frontmatter!.date); if (!frontmatter) notFound();
const d = new Date(frontmatter.date);
const formattedDates = { const formattedDates = {
dateISO: d.toISOString(), dateISO: d.toISOString(),
@@ -60,10 +72,16 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
minute: "2-digit", minute: "2-digit",
timeZoneName: "short", timeZoneName: "short",
}), }),
dateDisplay: d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }), dateDisplay: d.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}),
}; };
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`); const { default: MDXContent } = await import(
`../../../${POSTS_DIR}/${slug}/index.mdx`
);
return ( return (
<> <>
@@ -71,18 +89,18 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
item={{ item={{
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BlogPosting", "@type": "BlogPosting",
headline: frontmatter!.title, headline: frontmatter?.title,
description: frontmatter!.description, description: frontmatter?.description,
url: frontmatter!.permalink, url: frontmatter?.permalink,
image: { image: {
"@type": "ImageObject", "@type": "ImageObject",
contentUrl: `${env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${frontmatter!.slug}/opengraph-image`, contentUrl: `${env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${frontmatter?.slug}/opengraph-image`,
width: `${ogImageSize.width}`, width: `${ogImageSize.width}`,
height: `${ogImageSize.height}`, height: `${ogImageSize.height}`,
}, },
keywords: frontmatter!.tags?.join(", "), keywords: frontmatter?.tags?.join(", "),
datePublished: frontmatter!.date, datePublished: frontmatter?.date,
dateModified: frontmatter!.date, dateModified: frontmatter?.date,
inLanguage: env.NEXT_PUBLIC_SITE_LOCALE, inLanguage: env.NEXT_PUBLIC_SITE_LOCALE,
license: `https://spdx.org/licenses/${siteConfig.license}.html`, license: `https://spdx.org/licenses/${siteConfig.license}.html`,
author: { author: {
@@ -92,26 +110,34 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
}} }}
/> />
<div className="text-foreground/70 flex flex-wrap justify-items-start gap-4 text-[13px] tracking-wide"> <div className="flex flex-wrap justify-items-start gap-4 text-[13px] text-foreground/70 tracking-wide">
<Link <Link
href={`/${POSTS_DIR}/${frontmatter!.slug}`} href={`/${POSTS_DIR}/${frontmatter?.slug}`}
className={"text-foreground/70 flex flex-nowrap items-center gap-1.5 whitespace-nowrap hover:no-underline"} className={
"flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-foreground/70 hover:no-underline"
}
>
<CalendarDaysIcon
className="inline size-3 shrink-0"
aria-hidden="true"
/>
<time
dateTime={formattedDates.dateISO}
title={formattedDates.dateTitle}
suppressHydrationWarning
> >
<CalendarDaysIcon className="inline size-3 shrink-0" aria-hidden="true" />
<time dateTime={formattedDates.dateISO} title={formattedDates.dateTitle} suppressHydrationWarning>
{formattedDates.dateDisplay} {formattedDates.dateDisplay}
</time> </time>
</Link> </Link>
{frontmatter!.tags && ( {frontmatter?.tags && (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<TagIcon className="inline size-3 shrink-0" aria-hidden="true" /> <TagIcon className="inline size-3 shrink-0" aria-hidden="true" />
{frontmatter!.tags.map((tag) => ( {frontmatter?.tags.map((tag) => (
<span <span
key={tag} key={tag}
title={tag} title={tag}
className="before:text-foreground/40 mx-px lowercase before:pr-0.5 before:content-['\0023'] first-of-type:ml-0 last-of-type:mr-0" className="mx-px lowercase before:pr-0.5 before:text-foreground/40 before:content-['\0023'] first-of-type:ml-0 last-of-type:mr-0"
aria-label={`Tagged with ${tag}`}
> >
{tag} {tag}
</span> </span>
@@ -120,35 +146,46 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
)} )}
<Link <Link
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}/blob/main/${POSTS_DIR}/${frontmatter!.slug}/index.mdx`} href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}/blob/main/${POSTS_DIR}/${frontmatter?.slug}/index.mdx`}
title={`Edit "${frontmatter!.title}" on GitHub`} title={`Edit "${frontmatter?.title}" on GitHub`}
className={"text-foreground/70 flex flex-nowrap items-center gap-1.5 whitespace-nowrap hover:no-underline"} className={
"flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-foreground/70 hover:no-underline"
}
> >
<SquarePenIcon className="inline size-3 shrink-0" aria-hidden="true" /> <SquarePenIcon
className="inline size-3 shrink-0"
aria-hidden="true"
/>
<span>Improve This Post</span> <span>Improve This Post</span>
</Link> </Link>
<Link <Link
href={`/${POSTS_DIR}/${frontmatter!.slug}#comments`} href={`/${POSTS_DIR}/${frontmatter?.slug}#comments`}
className="text-foreground/70 flex flex-nowrap items-center gap-1.5 whitespace-nowrap hover:no-underline" className="flex flex-nowrap items-center gap-1.5 whitespace-nowrap text-foreground/70 hover:no-underline"
> >
<MessagesSquareIcon className="inline size-3 shrink-0" aria-hidden="true" /> <MessagesSquareIcon
<CommentCount slug={`${POSTS_DIR}/${frontmatter!.slug}`} /> className="inline size-3 shrink-0"
aria-hidden="true"
/>
<CommentCount slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
</Link> </Link>
<div className="flex min-w-14 flex-nowrap items-center gap-1.5 whitespace-nowrap"> <div className="flex min-w-14 flex-nowrap items-center gap-1.5 whitespace-nowrap">
<EyeIcon className="inline size-3 shrink-0" aria-hidden="true" /> <EyeIcon className="inline size-3 shrink-0" aria-hidden="true" />
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} /> <ViewCounter slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
</div> </div>
</div> </div>
<h1 <h1
className="my-5 text-3xl font-medium tracking-tight" className="my-5 font-medium text-3xl tracking-tight"
style={{ viewTransitionName: `note-title-${frontmatter!.slug}` }} style={{ viewTransitionName: `note-title-${frontmatter?.slug}` }}
> >
<Link <Link
href={`/${POSTS_DIR}/${frontmatter!.slug}`} href={`/${POSTS_DIR}/${frontmatter?.slug}`}
dangerouslySetInnerHTML={{ __html: frontmatter!.htmlTitle || frontmatter!.title }} // biome-ignore lint/security/noDangerouslySetInnerHtml: htmlTitle is sanitized by rehypeSanitize in lib/posts.ts
dangerouslySetInnerHTML={{
__html: frontmatter.htmlTitle || frontmatter.title,
}}
className="text-foreground hover:no-underline" className="text-foreground hover:no-underline"
/> />
</h1> </h1>
@@ -157,13 +194,15 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
<section id="comments" className="isolate my-8 w-full border-t-2 pt-8"> <section id="comments" className="isolate my-8 w-full border-t-2 pt-8">
<div className="mx-auto w-full max-w-3xl space-y-6"> <div className="mx-auto w-full max-w-3xl space-y-6">
{frontmatter!.noComments ? ( {frontmatter?.noComments ? (
<div className="bg-muted/40 flex justify-center rounded-lg px-6 py-12"> <div className="flex justify-center rounded-lg bg-muted/40 px-6 py-12">
<p className="text-center text-lg font-medium">Comments are closed.</p> <p className="text-center font-medium text-lg">
Comments are closed.
</p>
</div> </div>
) : ( ) : (
<Suspense fallback={<CommentsSkeleton />}> <Suspense fallback={<CommentsSkeleton />}>
<Comments slug={`${POSTS_DIR}/${frontmatter!.slug}`} /> <Comments slug={`${POSTS_DIR}/${frontmatter?.slug}`} />
</Suspense> </Suspense>
)} )}
</div> </div>
+29 -16
View File
@@ -1,9 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { PageTitle } from "@/components/layout/page-title"; import { PageTitle } from "@/components/layout/page-title";
import { PostStats, PostStatsProvider } from "@/components/post-stats"; import { PostStats, PostStatsProvider } from "@/components/post-stats";
import { getFrontMatter, POSTS_DIR, type FrontMatter } from "@/lib/posts";
import { createMetadata } from "@/lib/metadata";
import authorConfig from "@/lib/config/author"; import authorConfig from "@/lib/config/author";
import { createMetadata } from "@/lib/metadata";
import { type FrontMatter, getFrontMatter, POSTS_DIR } from "@/lib/posts";
export const metadata = createMetadata({ export const metadata = createMetadata({
title: "Notes", title: "Notes",
@@ -28,7 +28,10 @@ const PostsList = async () => {
minute: "2-digit", minute: "2-digit",
timeZoneName: "short", timeZoneName: "short",
}), }),
dateDisplay: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }), dateDisplay: d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
}; };
}); });
@@ -41,30 +44,41 @@ const PostsList = async () => {
})[]; })[];
} = {}; } = {};
formattedPosts.forEach((post) => { for (const post of formattedPosts) {
(postsByYear[post.year] || (postsByYear[post.year] = [])).push(post); if (!postsByYear[post.year]) {
}); postsByYear[post.year] = [];
}
postsByYear[post.year].push(post);
}
const sections: React.ReactNode[] = []; const sections: React.ReactNode[] = [];
Object.entries(postsByYear).forEach(([year, posts]) => { Object.entries(postsByYear).forEach(([year, posts]) => {
sections.push( sections.push(
<section className="my-8 first-of-type:mt-0 last-of-type:mb-0" key={year}> <section className="my-8 first-of-type:mt-0 last-of-type:mb-0" key={year}>
<h2 id={year} className="mt-0 mb-4 text-2xl font-semibold tracking-tight"> <h2
id={year}
className="mt-0 mb-4 font-semibold text-2xl tracking-tight"
>
{year} {year}
</h2> </h2>
<ul className="space-y-4"> <ul className="space-y-4">
{posts.map(({ slug, dateISO, dateTitle, dateDisplay, title, htmlTitle }) => ( {posts.map(
({ slug, dateISO, dateTitle, dateDisplay, title, htmlTitle }) => (
<li className="flex text-base leading-relaxed" key={slug}> <li className="flex text-base leading-relaxed" key={slug}>
<span className="text-muted-foreground w-18 shrink-0 md:w-22"> <span className="w-18 shrink-0 text-muted-foreground md:w-22">
<time dateTime={dateISO} title={dateTitle} suppressHydrationWarning> <time
dateTime={dateISO}
title={dateTitle}
suppressHydrationWarning
>
{dateDisplay} {dateDisplay}
</time> </time>
</span> </span>
<div className="space-x-2"> <div className="space-x-2">
{/* htmlTitle is sanitized by rehypeSanitize in lib/posts.ts with strict allowlist: only code, em, strong tags */}
<Link <Link
href={`/${POSTS_DIR}/${slug}`} href={`/${POSTS_DIR}/${slug}`}
// biome-ignore lint/security/noDangerouslySetInnerHtml: htmlTitle is sanitized by rehypeSanitize in lib/posts.ts
dangerouslySetInnerHTML={{ __html: htmlTitle || title }} dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
className="mr-2.5 underline-offset-4 hover:underline" className="mr-2.5 underline-offset-4 hover:underline"
style={{ viewTransitionName: `note-title-${slug}` }} style={{ viewTransitionName: `note-title-${slug}` }}
@@ -73,9 +87,10 @@ const PostsList = async () => {
<PostStats slug={`${POSTS_DIR}/${slug}`} /> <PostStats slug={`${POSTS_DIR}/${slug}`} />
</div> </div>
</li> </li>
))} ),
)}
</ul> </ul>
</section> </section>,
); );
}); });
@@ -83,8 +98,7 @@ const PostsList = async () => {
return <>{sections.reverse()}</>; return <>{sections.reverse()}</>;
}; };
const Page = async () => { const Page = async () => (
return (
<> <>
<PageTitle canonical="/notes">Notes</PageTitle> <PageTitle canonical="/notes">Notes</PageTitle>
<PostStatsProvider> <PostStatsProvider>
@@ -92,6 +106,5 @@ const Page = async () => {
</PostStatsProvider> </PostStatsProvider>
</> </>
); );
};
export default Page; export default Page;
+37 -20
View File
@@ -1,22 +1,23 @@
import Link from "next/link";
import { LockIcon } from "lucide-react"; import { LockIcon } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Page = () => { const Page = () => (
return (
<div <div
className={cn( className={cn(
"prose prose-neutral dark:prose-invert prose-sm max-w-none", "prose prose-neutral dark:prose-invert prose-sm max-w-none",
"prose-headings:font-semibold prose-headings:text-primary prose-headings:tracking-tight prose-headings:mt-0 prose-headings:mb-3", "prose-headings:mt-0 prose-headings:mb-3 prose-headings:font-semibold prose-headings:text-primary prose-headings:tracking-tight",
"prose-p:text-foreground/90 prose-p:my-3 prose-p:leading-[1.75] md:prose-p:leading-relaxed prose-strong:text-primary prose-li:text-foreground/80", "prose-p:my-3 prose-li:text-foreground/80 prose-p:text-foreground/90 prose-strong:text-primary prose-p:leading-[1.75] md:prose-p:leading-relaxed",
"prose-a:text-primary prose-a:font-medium prose-a:underline prose-a:underline-offset-4", "prose-a:font-medium prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
"prose-code:bg-muted prose-code:text-foreground prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-[0.9em] prose-code:before:content-none prose-code:after:content-none", "prose-code:rounded-sm prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.9em] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none",
"[&_table]:!border-[color:var(--border)] [&_td]:!border-[color:var(--border)] [&_th]:!border-[color:var(--border)]" "[&_table]:!border-[color:var(--border)] [&_td]:!border-[color:var(--border)] [&_th]:!border-[color:var(--border)]",
)} )}
> >
<h1 className="text-2xl font-medium"> <h1 className="font-medium text-2xl">
Hi there! I&rsquo;m Jake.{" "} Hi there! I&rsquo;m Jake.{" "}
<span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-2xl">👋</span> <span className="ml-0.5 inline-block origin-[65%_80%] text-2xl motion-safe:animate-wave">
👋
</span>
</h1> </h1>
<h2 className="font-normal"> <h2 className="font-normal">
@@ -31,8 +32,8 @@ const Page = () => {
</h2> </h2>
<p> <p>
I specialize in using TypeScript, React, and Next.js to make lightweight frontends with dynamic and powerful I specialize in using TypeScript, React, and Next.js to make lightweight
backends. frontends with dynamic and powerful backends.
</p> </p>
<p> <p>
@@ -59,11 +60,17 @@ const Page = () => {
<p> <p>
I fell in love with{" "} I fell in love with{" "}
<Link href="/previously" title="My Terrible, Horrible, No Good, Very Bad First Websites"> <Link
href="/previously"
title="My Terrible, Horrible, No Good, Very Bad First Websites"
>
frontend web design frontend web design
</Link>{" "} </Link>{" "}
and{" "} and{" "}
<Link href="/notes/my-first-code" title="Jake's Bulletin Board, circa 2003"> <Link
href="/notes/my-first-code"
title="Jake's Bulletin Board, circa 2003"
>
backend coding backend coding
</Link>{" "} </Link>{" "}
when my only source of income was{" "} when my only source of income was{" "}
@@ -77,7 +84,10 @@ const Page = () => {
> >
the Tooth Fairy the Tooth Fairy
</Link> </Link>
. <span className="text-muted-foreground">(I&rsquo;ve improved a bit since then, I think?)</span> .{" "}
<span className="text-muted-foreground">
(I&rsquo;ve improved a bit since then, I think?)
</span>
</p> </p>
<p> <p>
@@ -106,11 +116,19 @@ const Page = () => {
<p className="mt-2 mb-0 text-sm leading-normal"> <p className="mt-2 mb-0 text-sm leading-normal">
You can find my work on{" "} You can find my work on{" "}
<a href="https://github.com/jakejarvis" target="_blank" rel="noopener noreferrer me"> <a
href="https://github.com/jakejarvis"
target="_blank"
rel="noopener noreferrer me"
>
GitHub GitHub
</a>{" "} </a>{" "}
and{" "} and{" "}
<a href="https://www.linkedin.com/in/jakejarvis/" target="_blank" rel="noopener noreferrer me"> <a
href="https://www.linkedin.com/in/jakejarvis/"
target="_blank"
rel="noopener noreferrer me"
>
LinkedIn LinkedIn
</a> </a>
. I&rsquo;m always available to connect over{" "} . I&rsquo;m always available to connect over{" "}
@@ -123,10 +141,10 @@ const Page = () => {
target="_blank" target="_blank"
rel="noopener pgpkey" rel="noopener pgpkey"
title="Download my PGP key" title="Download my PGP key"
className="not-prose text-muted-foreground hover:text-primary space-x-1 px-0.5 text-nowrap no-underline hover:no-underline" className="not-prose space-x-1 text-nowrap px-0.5 text-muted-foreground no-underline hover:text-primary hover:no-underline"
> >
<LockIcon className="inline size-2.5" aria-hidden="true" /> <LockIcon className="inline size-2.5" aria-hidden="true" />
<code className="text-[9px] leading-none tracking-wider text-wrap [word-spacing:-3px]"> <code className="text-wrap text-[9px] leading-none tracking-wider [word-spacing:-3px]">
2B0C 9CF2 51E6 9A39 2B0C 9CF2 51E6 9A39
</code> </code>
</a> </a>
@@ -135,6 +153,5 @@ const Page = () => {
</p> </p>
</div> </div>
); );
};
export default Page; export default Page;
+15 -12
View File
@@ -1,10 +1,10 @@
import "server-only"; import "server-only";
import { env } from "@/lib/env";
import { cacheLife } from "next/cache";
import * as cheerio from "cheerio";
import { graphql } from "@octokit/graphql"; import { graphql } from "@octokit/graphql";
import type { Repository, User } from "@octokit/graphql-schema"; import type { Repository, User } from "@octokit/graphql-schema";
import * as cheerio from "cheerio";
import { cacheLife } from "next/cache";
import { env } from "@/lib/env";
export const getContributions = async (): Promise< export const getContributions = async (): Promise<
Array<{ Array<{
@@ -18,12 +18,15 @@ export const getContributions = async (): Promise<
// thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts // thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts
try { try {
const response = await fetch(`https://github.com/users/${env.NEXT_PUBLIC_GITHUB_USERNAME}/contributions`, { const response = await fetch(
`https://github.com/users/${env.NEXT_PUBLIC_GITHUB_USERNAME}/contributions`,
{
headers: { headers: {
referer: `https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`, referer: `https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`,
"x-requested-with": "XMLHttpRequest", "x-requested-with": "XMLHttpRequest",
}, },
}); },
);
const $ = cheerio.load(await response.text()); const $ = cheerio.load(await response.text());
@@ -38,15 +41,15 @@ export const getContributions = async (): Promise<
const dayTooltips = $(".js-calendar-graph tool-tip") const dayTooltips = $(".js-calendar-graph tool-tip")
.toArray() .toArray()
// eslint-disable-next-line @typescript-eslint/no-explicit-any // biome-ignore lint/suspicious/noExplicitAny: cheerio DOM element map
.reduce<Record<string, any>>((map, elem) => { .reduce<Record<string, any>>((map, elem) => {
map[elem.attribs["for"]] = elem; map[elem.attribs.for] = elem;
return map; return map;
}, {}); }, {});
return days.map((day) => { return days.map((day) => {
const attr = { const attr = {
id: day.attribs["id"], id: day.attribs.id,
date: day.attribs["data-date"], date: day.attribs["data-date"],
level: day.attribs["data-level"], level: day.attribs["data-level"],
}; };
@@ -57,12 +60,12 @@ export const getContributions = async (): Promise<
if (text) { if (text) {
const countMatch = text.data.trim().match(/^\d+/); const countMatch = text.data.trim().match(/^\d+/);
if (countMatch) { if (countMatch) {
count = parseInt(countMatch[0]); count = parseInt(countMatch[0], 10);
} }
} }
} }
const level = parseInt(attr.level); const level = parseInt(attr.level, 10);
return { return {
date: attr.date, date: attr.date,
@@ -120,10 +123,10 @@ export const getRepos = async (): Promise<Repository[] | undefined> => {
accept: "application/vnd.github.v3+json", accept: "application/vnd.github.v3+json",
authorization: `token ${env.GITHUB_TOKEN}`, authorization: `token ${env.GITHUB_TOKEN}`,
}, },
} },
); );
return user.repositories.edges?.map((edge) => edge!.node as Repository); return user.repositories.edges?.map((edge) => edge?.node as Repository);
} catch (error) { } catch (error) {
console.error("[server/github] Failed to fetch repositories:", error); console.error("[server/github] Failed to fetch repositories:", error);
return []; return [];
+74 -41
View File
@@ -1,16 +1,16 @@
import { env } from "@/lib/env";
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { GitForkIcon, StarIcon } from "lucide-react"; import { GitForkIcon, StarIcon } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton"; import { notFound } from "next/navigation";
import { PageTitle } from "@/components/layout/page-title"; import { Suspense } from "react";
import { RelativeTime } from "@/components/relative-time";
import { ActivityCalendar } from "@/components/activity-calendar"; import { ActivityCalendar } from "@/components/activity-calendar";
import { GitHubIcon } from "@/components/icons"; import { GitHubIcon } from "@/components/icons";
import { cn } from "@/lib/utils"; import { PageTitle } from "@/components/layout/page-title";
import { createMetadata } from "@/lib/metadata"; import { RelativeTime } from "@/components/relative-time";
import { getContributions, getRepos } from "./github";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { env } from "@/lib/env";
import { createMetadata } from "@/lib/metadata";
import { cn } from "@/lib/utils";
import { getContributions, getRepos } from "./github";
export const metadata = createMetadata({ export const metadata = createMetadata({
title: "Projects", title: "Projects",
@@ -22,18 +22,23 @@ const Page = async () => {
// don't fail the entire site build if the required config for this page is missing, just return a 404 since this page // don't fail the entire site build if the required config for this page is missing, just return a 404 since this page
// would be mostly blank anyways. // would be mostly blank anyways.
if (!env.GITHUB_TOKEN) { if (!env.GITHUB_TOKEN) {
console.error("[/projects] I can't fetch anything from GitHub without 'GITHUB_TOKEN' set!"); console.error(
"[/projects] I can't fetch anything from GitHub without 'GITHUB_TOKEN' set!",
);
notFound(); notFound();
} }
// fetch the repos and contributions in parallel // fetch the repos and contributions in parallel
const [contributions, repos] = await Promise.all([getContributions(), getRepos()]); const [contributions, repos] = await Promise.all([
getContributions(),
getRepos(),
]);
return ( return (
<> <>
<PageTitle canonical="/projects">Projects</PageTitle> <PageTitle canonical="/projects">Projects</PageTitle>
<h2 className="my-3.5 text-xl font-medium"> <h2 className="my-3.5 font-medium text-xl">
<a <a
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`} href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`}
target="_blank" target="_blank"
@@ -50,11 +55,13 @@ const Page = async () => {
<ActivityCalendar data={contributions} noun="contribution" /> <ActivityCalendar data={contributions} noun="contribution" />
</div> </div>
) : ( ) : (
<p className="text-muted-foreground my-4 text-center">Unable to load contribution data at this time.</p> <p className="my-4 text-center text-muted-foreground">
Unable to load contribution data at this time.
</p>
)} )}
</Suspense> </Suspense>
<h2 className="my-3.5 text-xl font-medium"> <h2 className="my-3.5 font-medium text-xl">
<a <a
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&sort=stargazers`} href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&sort=stargazers`}
target="_blank" target="_blank"
@@ -68,61 +75,85 @@ const Page = async () => {
{repos && repos.length > 0 ? ( {repos && repos.length > 0 ? (
<div className="row-auto grid w-full grid-cols-none gap-4 md:grid-cols-2"> <div className="row-auto grid w-full grid-cols-none gap-4 md:grid-cols-2">
{repos.map((repo) => ( {repos.map((repo) => (
<div key={repo!.name} className="border-ring/30 h-fit space-y-1.5 rounded-2xl border-1 px-4 py-3 shadow-xs"> <div
key={repo?.name}
className="h-fit space-y-1.5 rounded-2xl border-1 border-ring/30 px-4 py-3 shadow-xs"
>
<a <a
href={repo!.url} href={repo?.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-block text-base leading-relaxed font-semibold text-[#0969da] hover:underline dark:text-[#4493f8]" className="inline-block font-semibold text-[#0969da] text-base leading-relaxed hover:underline dark:text-[#4493f8]"
> >
{repo!.name} {repo?.name}
</a> </a>
{repo!.description && <p className="text-foreground/85 text-sm leading-relaxed">{repo!.description}</p>} {repo?.description && (
<p className="text-foreground/85 text-sm leading-relaxed">
{repo?.description}
</p>
)}
<div className="flex flex-wrap gap-x-4 text-[0.825rem] leading-loose whitespace-nowrap"> <div className="flex flex-wrap gap-x-4 whitespace-nowrap text-[0.825rem] leading-loose">
{repo!.primaryLanguage && ( {repo?.primaryLanguage && (
<div className="text-muted-foreground inline-flex flex-nowrap items-center gap-2"> <div className="inline-flex flex-nowrap items-center gap-2 text-muted-foreground">
{repo!.primaryLanguage.color && ( {repo?.primaryLanguage.color && (
<span <span
className="inline-block size-4 rounded-full bg-[var(--language-color)]" className="inline-block size-4 rounded-full bg-[var(--language-color)]"
style={{ ["--language-color" as string]: repo!.primaryLanguage.color }} style={{
["--language-color" as string]:
repo?.primaryLanguage.color,
}}
/> />
)} )}
<span>{repo!.primaryLanguage.name}</span> <span>{repo?.primaryLanguage.name}</span>
</div> </div>
)} )}
{repo!.stargazerCount > 0 && ( {repo?.stargazerCount > 0 && (
<a <a
href={`${repo!.url}/stargazers`} href={`${repo?.url}/stargazers`}
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.stargazerCount)} ${repo!.stargazerCount === 1 ? "star" : "stars"}`} title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo?.stargazerCount)} ${repo?.stargazerCount === 1 ? "star" : "stars"}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary inline-flex flex-nowrap items-center gap-2 hover:no-underline" className="inline-flex flex-nowrap items-center gap-2 text-muted-foreground hover:text-primary hover:no-underline"
> >
<StarIcon className="inline-block size-4 shrink-0" aria-hidden="true" /> <StarIcon
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.stargazerCount)}</span> className="inline-block size-4 shrink-0"
aria-hidden="true"
/>
<span>
{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(
repo?.stargazerCount,
)}
</span>
</a> </a>
)} )}
{repo!.forkCount > 0 && ( {repo?.forkCount > 0 && (
<a <a
href={`${repo!.url}/network/members`} href={`${repo?.url}/network/members`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.forkCount)} ${repo!.forkCount === 1 ? "fork" : "forks"}`} title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo?.forkCount)} ${repo?.forkCount === 1 ? "fork" : "forks"}`}
className="text-muted-foreground hover:text-primary inline-flex flex-nowrap items-center gap-2 hover:no-underline" className="inline-flex flex-nowrap items-center gap-2 text-muted-foreground hover:text-primary hover:no-underline"
> >
<GitForkIcon className="inline-block size-4" aria-hidden="true" /> <GitForkIcon
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.forkCount)}</span> className="inline-block size-4"
aria-hidden="true"
/>
<span>
{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(
repo?.forkCount,
)}
</span>
</a> </a>
)} )}
<div className="text-muted-foreground whitespace-nowrap"> <div className="whitespace-nowrap text-muted-foreground">
<Suspense fallback={null}> <Suspense fallback={null}>
<span> <span>
Updated <RelativeTime date={repo!.pushedAt} /> Updated <RelativeTime date={repo?.pushedAt} />
</span> </span>
</Suspense> </Suspense>
</div> </div>
@@ -131,10 +162,12 @@ const Page = async () => {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground my-4 text-center">Unable to load repository data at this time.</p> <p className="my-4 text-center text-muted-foreground">
Unable to load repository data at this time.
</p>
)} )}
<p className="mt-6 mb-0 text-center text-base font-medium"> <p className="mt-6 mb-0 text-center font-medium text-base">
<Button variant="secondary" asChild> <Button variant="secondary" asChild>
<a <a
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&type=source&sort=stargazers`} href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&type=source&sort=stargazers`}
+1 -1
View File
@@ -1,5 +1,5 @@
import { env } from "@/lib/env";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { env } from "@/lib/env";
const robots = (): MetadataRoute.Robots => ({ const robots = (): MetadataRoute.Robots => ({
rules: [ rules: [
+3 -3
View File
@@ -1,8 +1,8 @@
import { env } from "@/lib/env"; import path from "node:path";
import path from "path";
import glob from "fast-glob"; import glob from "fast-glob";
import { getFrontMatter } from "@/lib/posts";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { env } from "@/lib/env";
import { getFrontMatter } from "@/lib/posts";
// routes in /app (in other words, directories containing a page.tsx/mdx file) are automatically included; add a route // routes in /app (in other words, directories containing a page.tsx/mdx file) are automatically included; add a route
// here to exclude it. // here to exclude it.
+81
View File
@@ -0,0 +1,81 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": true,
"includes": [
"**/*.js",
"**/*.jsx",
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.mjs",
"**/*.json",
"!**/node_modules",
"!**/.next",
"!**/.swc",
"!**/.vercel",
"!**/drizzle",
"!**/public"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80
},
"linter": {
"enabled": true,
"domains": {
"next": "recommended",
"react": "recommended"
},
"rules": {
"recommended": true,
"correctness": {
"useExhaustiveDependencies": "warn"
},
"suspicious": {
"noUnknownAtRules": "off"
},
"a11y": {
"noSvgWithoutTitle": "off",
"useAnchorContent": "off",
"useSemanticElements": "off"
},
"style": {
"useConsistentArrowReturn": "error"
},
"nursery": {
"useSortedClasses": {
"level": "error",
"options": {
"attributes": ["classList"],
"functions": ["cn", "clsx", "cva"]
}
},
"noFloatingPromises": "error",
"noMisusedPromises": "error",
"useDestructuring": "error"
}
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}
+11 -4
View File
@@ -1,7 +1,14 @@
"use client"; "use client";
import { ActivityCalendar as ActivityCalendarPrimitive, type Activity } from "react-activity-calendar"; import {
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; type Activity,
ActivityCalendar as ActivityCalendarPrimitive,
} from "react-activity-calendar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const ActivityCalendar = ({ const ActivityCalendar = ({
@@ -20,7 +27,7 @@ const ActivityCalendar = ({
String.raw`**:[.react-activity-calendar\_\_count,.react-activity-calendar\_\_legend-month,.react-activity-calendar\_\_legend-colors]:text-muted-foreground`, String.raw`**:[.react-activity-calendar\_\_count,.react-activity-calendar\_\_legend-month,.react-activity-calendar\_\_legend-colors]:text-muted-foreground`,
"[--activity-0:#ebedf0] [--activity-1:#9be9a8] [--activity-2:#40c463] [--activity-3:#30a14e] [--activity-4:#216e39]", "[--activity-0:#ebedf0] [--activity-1:#9be9a8] [--activity-2:#40c463] [--activity-3:#30a14e] [--activity-4:#216e39]",
"dark:[--activity-0:#252525] dark:[--activity-1:#033a16] dark:[--activity-2:#196c2e] dark:[--activity-3:#2ea043] dark:[--activity-4:#56d364]", "dark:[--activity-0:#252525] dark:[--activity-1:#033a16] dark:[--activity-2:#196c2e] dark:[--activity-3:#2ea043] dark:[--activity-4:#56d364]",
className className,
)} )}
{...rest} {...rest}
> >
@@ -45,7 +52,7 @@ const ActivityCalendar = ({
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{block}</TooltipTrigger> <TooltipTrigger asChild>{block}</TooltipTrigger>
<TooltipContent> <TooltipContent>
<span className="text-[0.825rem] font-medium">{`${activity.count === 0 ? "No" : activity.count} ${noun}${activity.count === 1 ? "" : "s"} on ${new Date(activity.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`}</span> <span className="font-medium text-[0.825rem]">{`${activity.count === 0 ? "No" : activity.count} ${noun}${activity.count === 1 ? "" : "s"} on ${new Date(activity.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`}</span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
+17 -8
View File
@@ -1,5 +1,5 @@
import { codeToHtml } from "shiki";
import { cacheLife } from "next/cache"; import { cacheLife } from "next/cache";
import { codeToHtml } from "shiki";
import { CopyButton } from "@/components/copy-button"; import { CopyButton } from "@/components/copy-button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -12,7 +12,10 @@ const getTextContent = (node: React.ReactNode): string => {
if (typeof node === "string" || typeof node === "number") return String(node); if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(getTextContent).join(""); if (Array.isArray(node)) return node.map(getTextContent).join("");
if (typeof node === "object" && "props" in node) { if (typeof node === "object" && "props" in node) {
return getTextContent((node as React.ReactElement<{ children?: React.ReactNode }>).props.children); return getTextContent(
(node as React.ReactElement<{ children?: React.ReactNode }>).props
.children,
);
} }
return ""; return "";
}; };
@@ -31,7 +34,12 @@ const renderCode = async (code: string, lang: string): Promise<string> => {
}); });
}; };
const CodeBlock = async ({ children, className, showLineNumbers = true, ...props }: CodeBlockProps) => { const CodeBlock = async ({
children,
className,
showLineNumbers = true,
...props
}: CodeBlockProps) => {
// Escape hatch for non-code pre blocks // Escape hatch for non-code pre blocks
if (!children || typeof children !== "object" || !("props" in children)) { if (!children || typeof children !== "object" || !("props" in children)) {
return ( return (
@@ -58,14 +66,15 @@ const CodeBlock = async ({ children, className, showLineNumbers = true, ...props
data-lang={lang} data-lang={lang}
data-line-numbers={showLineNumbers || undefined} data-line-numbers={showLineNumbers || undefined}
className={cn( className={cn(
"bg-code text-code-foreground overflow-x-auto overflow-y-hidden rounded-xl text-[13px] leading-normal outline-none", "overflow-x-auto overflow-y-hidden rounded-xl bg-code text-[13px] text-code-foreground leading-normal outline-none",
"[&_span]:!bg-transparent [&_span[style*='color']]:dark:!text-(--shiki-dark)", "[&_span]:!bg-transparent [&_span[style*='color']]:dark:!text-(--shiki-dark)",
"[&_pre]:m-0 [&_pre]:rounded-xl [&_pre]:!bg-transparent", "[&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:rounded-xl",
"[&_code]:white-space-pre [&_code]:grid [&_code]:min-w-full [&_code]:px-4 [&_code]:py-3.5 [&_code]:[counter-reset:line]", "[&_code]:white-space-pre [&_code]:grid [&_code]:min-w-full [&_code]:px-4 [&_code]:py-3.5 [&_code]:[counter-reset:line]",
"[&_.line]:min-h-1lh [&_.line]:inline-block [&_.line]:w-full [&_.line]:py-0.5", "[&_.line]:inline-block [&_.line]:min-h-1lh [&_.line]:w-full [&_.line]:py-0.5",
"data-[line-numbers]:[&_.line]:before:text-code-number data-[line-numbers]:[&_.line]:before:mr-6 data-[line-numbers]:[&_.line]:before:inline-block data-[line-numbers]:[&_.line]:before:w-5 data-[line-numbers]:[&_.line]:before:text-right data-[line-numbers]:[&_.line]:before:content-[counter(line)] data-[line-numbers]:[&_.line]:before:[counter-increment:line]", "data-[line-numbers]:[&_.line]:before:mr-6 data-[line-numbers]:[&_.line]:before:inline-block data-[line-numbers]:[&_.line]:before:w-5 data-[line-numbers]:[&_.line]:before:text-right data-[line-numbers]:[&_.line]:before:text-code-number data-[line-numbers]:[&_.line]:before:content-[counter(line)] data-[line-numbers]:[&_.line]:before:[counter-increment:line]",
className className,
)} )}
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted Shiki-generated syntax-highlighted HTML
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
</div> </div>
+29 -8
View File
@@ -1,8 +1,14 @@
"use client"; "use client";
import {
EditIcon,
EllipsisIcon,
Loader2Icon,
ReplyIcon,
Trash2Icon,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ReplyIcon, EditIcon, Trash2Icon, EllipsisIcon, Loader2Icon } from "lucide-react";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -17,12 +23,12 @@ import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { EditCommentForm, ReplyForm } from "./comment-form";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { deleteComment, type CommentWithUser } from "@/lib/server/comments"; import { type CommentWithUser, deleteComment } from "@/lib/server/comments";
import { EditCommentForm, ReplyForm } from "./comment-form";
type ActionMode = type ActionMode =
| { type: "idle" } | { type: "idle" }
@@ -69,7 +75,13 @@ const CommentActions = ({ comment }: { comment: CommentWithUser }) => {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setMode(mode.type === "replying" ? { type: "idle" } : { type: "replying" })} onClick={() =>
setMode(
mode.type === "replying"
? { type: "idle" }
: { type: "replying" },
)
}
> >
<ReplyIcon /> <ReplyIcon />
Reply Reply
@@ -93,7 +105,11 @@ const CommentActions = ({ comment }: { comment: CommentWithUser }) => {
disabled={isDeleting} disabled={isDeleting}
variant="destructive" variant="destructive"
> >
{isDeleting ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />} {isDeleting ? (
<Loader2Icon className="animate-spin" />
) : (
<Trash2Icon />
)}
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -113,11 +129,16 @@ const CommentActions = ({ comment }: { comment: CommentWithUser }) => {
</div> </div>
)} )}
<AlertDialog open={mode.type === "confirming-delete"} onOpenChange={(open) => !open && setMode({ type: "idle" })}> <AlertDialog
open={mode.type === "confirming-delete"}
onOpenChange={(open) => !open && setMode({ type: "idle" })}
>
<AlertDialogContent size="sm"> <AlertDialogContent size="sm">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete comment?</AlertDialogTitle> <AlertDialogTitle>Delete comment?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
+1 -3
View File
@@ -8,8 +8,7 @@ type CommentAvatarProps = {
className?: string; className?: string;
}; };
const CommentAvatar = ({ name, image, className }: CommentAvatarProps) => { const CommentAvatar = ({ name, image, className }: CommentAvatarProps) => (
return (
<Avatar className={cn("size-10", className)}> <Avatar className={cn("size-10", className)}>
{image && ( {image && (
<AvatarImage <AvatarImage
@@ -26,6 +25,5 @@ const CommentAvatar = ({ name, image, className }: CommentAvatarProps) => {
<AvatarFallback>{name.charAt(0).toUpperCase()}</AvatarFallback> <AvatarFallback>{name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
); );
};
export { CommentAvatar }; export { CommentAvatar };
+70 -21
View File
@@ -1,15 +1,19 @@
"use client"; "use client";
import { InfoIcon, Loader2Icon } from "lucide-react";
import { createContext, useContext, useState, useTransition } from "react"; import { createContext, useContext, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { InfoIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { MarkdownIcon } from "@/components/icons"; import { MarkdownIcon } from "@/components/icons";
import { CommentAvatar } from "./comment-avatar"; import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { createComment, updateComment } from "@/lib/server/comments"; import { createComment, updateComment } from "@/lib/server/comments";
import { CommentAvatar } from "./comment-avatar";
// Context for lifting form state to parent components // Context for lifting form state to parent components
type CommentFormContextValue = { type CommentFormContextValue = {
@@ -33,7 +37,9 @@ const CommentFormProvider = ({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
return ( return (
<CommentFormContext.Provider value={{ content, setContent, isPending, startTransition }}> <CommentFormContext.Provider
value={{ content, setContent, isPending, startTransition }}
>
{children} {children}
</CommentFormContext.Provider> </CommentFormContext.Provider>
); );
@@ -130,14 +136,14 @@ const SubmitButton = ({
// Markdown help popover (only shown for new comments) // Markdown help popover (only shown for new comments)
const MarkdownHelp = () => ( const MarkdownHelp = () => (
<p className="text-muted-foreground text-[0.8rem] leading-relaxed"> <p className="text-[0.8rem] text-muted-foreground leading-relaxed">
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" /> <MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" />
<span className="max-md:hidden">Basic&nbsp;</span> <span className="max-md:hidden">Basic&nbsp;</span>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className="text-primary decoration-primary/40 cursor-pointer font-semibold no-underline decoration-2 underline-offset-4 hover:underline" className="cursor-pointer font-semibold text-primary no-underline decoration-2 decoration-primary/40 underline-offset-4 hover:underline"
> >
<span>Markdown</span> <span>Markdown</span>
<span className="max-md:hidden">&nbsp;syntax</span> <span className="max-md:hidden">&nbsp;syntax</span>
@@ -149,7 +155,7 @@ const MarkdownHelp = () => (
Examples: Examples:
</p> </p>
<ul className="[&>li::marker]:text-muted-foreground my-2 list-inside list-disc pl-1 text-sm [&>li]:my-1.5 [&>li]:pl-1 [&>li]:text-nowrap [&>li::marker]:font-normal"> <ul className="my-2 list-inside list-disc pl-1 text-sm [&>li::marker]:font-normal [&>li::marker]:text-muted-foreground [&>li]:my-1.5 [&>li]:text-nowrap [&>li]:pl-1">
<li> <li>
<span className="font-bold">**bold**</span> <span className="font-bold">**bold**</span>
</li> </li>
@@ -158,13 +164,20 @@ const MarkdownHelp = () => (
</li> </li>
<li> <li>
[ [
<a href="https://jarv.is" target="_blank" rel="noopener" className="hover:no-underline"> <a
href="https://jarv.is"
target="_blank"
rel="noopener"
className="hover:no-underline"
>
links links
</a> </a>
](https://jarv.is) ](https://jarv.is)
</li> </li>
<li> <li>
<span className="bg-muted rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium">`code`</span> <span className="rounded-sm bg-muted px-[0.3rem] py-[0.2rem] font-medium font-mono text-sm">
`code`
</span>
</li> </li>
<li> <li>
~~<span className="line-through">strikethrough</span>~~ ~~<span className="line-through">strikethrough</span>~~
@@ -190,7 +203,8 @@ const MarkdownHelp = () => (
// New comment form - for creating top-level comments // New comment form - for creating top-level comments
const NewCommentForm = ({ slug }: { slug: string }) => { const NewCommentForm = ({ slug }: { slug: string }) => {
const { content, setContent, isPending, startTransition } = useCommentFormState(); const { content, setContent, isPending, startTransition } =
useCommentFormState();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -229,7 +243,11 @@ const NewCommentForm = ({ slug }: { slug: string }) => {
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
<MarkdownHelp /> <MarkdownHelp />
<SubmitButton isPending={isPending} disabled={!content.trim()} pendingLabel="Posting..."> <SubmitButton
isPending={isPending}
disabled={!content.trim()}
pendingLabel="Posting..."
>
Comment Comment
</SubmitButton> </SubmitButton>
</div> </div>
@@ -251,7 +269,8 @@ const ReplyForm = ({
onCancel: () => void; onCancel: () => void;
onSuccess?: () => void; onSuccess?: () => void;
}) => { }) => {
const { content, setContent, isPending, startTransition } = useCommentFormState(); const { content, setContent, isPending, startTransition } =
useCommentFormState();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -289,11 +308,20 @@ const ReplyForm = ({
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}> <Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isPending}
>
Cancel Cancel
</Button> </Button>
<SubmitButton isPending={isPending} disabled={!content.trim()} pendingLabel="Posting..."> <SubmitButton
isPending={isPending}
disabled={!content.trim()}
pendingLabel="Posting..."
>
Reply Reply
</SubmitButton> </SubmitButton>
</div> </div>
@@ -317,7 +345,8 @@ const EditCommentForm = ({
onCancel: () => void; onCancel: () => void;
onSuccess?: () => void; onSuccess?: () => void;
}) => { }) => {
const { content, setContent, isPending, startTransition } = useCommentFormState(initialContent); const { content, setContent, isPending, startTransition } =
useCommentFormState(initialContent);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -340,7 +369,12 @@ const EditCommentForm = ({
}; };
return ( return (
<form onSubmit={handleSubmit} className="space-y-4" data-intent="edit" data-slug={slug}> <form
onSubmit={handleSubmit}
className="space-y-4"
data-intent="edit"
data-slug={slug}
>
<div className="min-w-0 flex-1 space-y-4"> <div className="min-w-0 flex-1 space-y-4">
<CommentTextarea <CommentTextarea
content={content} content={content}
@@ -351,11 +385,20 @@ const EditCommentForm = ({
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}> <Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isPending}
>
Cancel Cancel
</Button> </Button>
<SubmitButton isPending={isPending} disabled={!content.trim()} pendingLabel="Updating..."> <SubmitButton
isPending={isPending}
disabled={!content.trim()}
pendingLabel="Updating..."
>
Edit Edit
</SubmitButton> </SubmitButton>
</div> </div>
@@ -364,4 +407,10 @@ const EditCommentForm = ({
); );
}; };
export { NewCommentForm, ReplyForm, EditCommentForm, CommentFormProvider, useCommentForm }; export {
NewCommentForm,
ReplyForm,
EditCommentForm,
CommentFormProvider,
useCommentForm,
};
+32 -11
View File
@@ -1,12 +1,12 @@
import Link from "next/link"; import Link from "next/link";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { RelativeTime } from "@/components/relative-time"; import { RelativeTime } from "@/components/relative-time";
import { CommentAvatar } from "./comment-avatar";
import { CommentActions } from "./comment-actions";
import { remarkGfm, remarkSmartypants } from "@/lib/remark";
import { rehypeExternalLinks } from "@/lib/rehype"; import { rehypeExternalLinks } from "@/lib/rehype";
import { cn } from "@/lib/utils"; import { remarkGfm, remarkSmartypants } from "@/lib/remark";
import type { CommentWithUser } from "@/lib/server/comments"; import type { CommentWithUser } from "@/lib/server/comments";
import { cn } from "@/lib/utils";
import { CommentActions } from "./comment-actions";
import { CommentAvatar } from "./comment-avatar";
const CommentSingle = ({ comment }: { comment: CommentWithUser }) => { const CommentSingle = ({ comment }: { comment: CommentWithUser }) => {
const divId = `comment-${comment.id.substring(0, 8)}`; const divId = `comment-${comment.id.substring(0, 8)}`;
@@ -15,7 +15,11 @@ const CommentSingle = ({ comment }: { comment: CommentWithUser }) => {
<div className="group scroll-mt-4" id={divId}> <div className="group scroll-mt-4" id={divId}>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="shrink-0"> <div className="shrink-0">
<CommentAvatar name={comment.user.name} image={comment.user.image} className="size-8 md:size-10" /> <CommentAvatar
name={comment.user.name}
image={comment.user.image}
className="size-8 md:size-10"
/>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -28,7 +32,10 @@ const CommentSingle = ({ comment }: { comment: CommentWithUser }) => {
> >
@{comment.user.name} @{comment.user.name}
</a> </a>
<Link href={`#${divId}`} className="text-muted-foreground text-xs leading-none hover:no-underline"> <Link
href={`#${divId}`}
className="text-muted-foreground text-xs leading-none hover:no-underline"
>
<RelativeTime date={comment.createdAt} /> <RelativeTime date={comment.createdAt} />
</Link> </Link>
</div> </div>
@@ -37,15 +44,29 @@ const CommentSingle = ({ comment }: { comment: CommentWithUser }) => {
className={cn( className={cn(
"isolate max-w-none text-[0.875rem] leading-relaxed", "isolate max-w-none text-[0.875rem] leading-relaxed",
"[&_p]:my-5 [&_p]:first:mt-0 [&_p]:last:mb-0", "[&_p]:my-5 [&_p]:first:mt-0 [&_p]:last:mb-0",
"[&_a]:text-primary [&_a]:decoration-primary/40 [&_a]:no-underline [&_a]:decoration-2 [&_a]:underline-offset-4 [&_a]:hover:underline", "[&_a]:text-primary [&_a]:no-underline [&_a]:decoration-2 [&_a]:decoration-primary/40 [&_a]:underline-offset-4 [&_a]:hover:underline",
"[&_code]:bg-muted [&_code]:rounded-sm [&_code]:px-[0.3rem] [&_code]:py-[0.2rem] [&_code]:font-medium", "[&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-[0.3rem] [&_code]:py-[0.2rem] [&_code]:font-medium",
"group-has-data-[intent=edit]:hidden" // hides the rendered comment when its own edit form is active "group-has-data-[intent=edit]:hidden", // hides the rendered comment when its own edit form is active
)} )}
> >
<Markdown <Markdown
remarkPlugins={[remarkGfm, remarkSmartypants]} remarkPlugins={[remarkGfm, remarkSmartypants]}
rehypePlugins={[[rehypeExternalLinks, { target: "_blank", rel: "noopener noreferrer nofollow" }]]} rehypePlugins={[
allowedElements={["p", "a", "em", "strong", "code", "pre", "blockquote", "del"]} [
rehypeExternalLinks,
{ target: "_blank", rel: "noopener noreferrer nofollow" },
],
]}
allowedElements={[
"p",
"a",
"em",
"strong",
"code",
"pre",
"blockquote",
"del",
]}
> >
{comment.content} {comment.content}
</Markdown> </Markdown>
+9 -6
View File
@@ -1,6 +1,6 @@
import { CommentSingle } from "./comment-single";
import { cn } from "@/lib/utils";
import type { CommentWithUser } from "@/lib/server/comments"; import type { CommentWithUser } from "@/lib/server/comments";
import { cn } from "@/lib/utils";
import { CommentSingle } from "./comment-single";
/** Maximum nesting depth for comment threads (0-indexed, so 2 = 3 levels deep) */ /** Maximum nesting depth for comment threads (0-indexed, so 2 = 3 levels deep) */
const MAX_NESTING_LEVEL = 2; const MAX_NESTING_LEVEL = 2;
@@ -15,13 +15,17 @@ const CommentThread = ({
replies: CommentWithUser[]; replies: CommentWithUser[];
allComments: Record<string, CommentWithUser[]>; allComments: Record<string, CommentWithUser[]>;
level?: number; level?: number;
}) => { }) => (
return (
<> <>
<CommentSingle comment={comment} /> <CommentSingle comment={comment} />
{replies.length > 0 && ( {replies.length > 0 && (
<div className={cn("mt-6 space-y-6", level < MAX_NESTING_LEVEL && "ml-6 border-l-2 pl-6")}> <div
className={cn(
"mt-6 space-y-6",
level < MAX_NESTING_LEVEL && "ml-6 border-l-2 pl-6",
)}
>
{replies.map((reply) => ( {replies.map((reply) => (
<CommentThread <CommentThread
key={reply.id} key={reply.id}
@@ -35,6 +39,5 @@ const CommentThread = ({
)} )}
</> </>
); );
};
export { CommentThread }; export { CommentThread };
+1 -3
View File
@@ -1,7 +1,6 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
const CommentsSkeleton = () => { const CommentsSkeleton = () => (
return (
<> <>
<Skeleton className="h-32 w-full" /> <Skeleton className="h-32 w-full" />
@@ -21,6 +20,5 @@ const CommentsSkeleton = () => {
</div> </div>
</> </>
); );
};
export { CommentsSkeleton }; export { CommentsSkeleton };
+9 -7
View File
@@ -1,9 +1,9 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { type CommentWithUser, getComments } from "@/lib/server/comments";
import { NewCommentForm } from "./comment-form"; import { NewCommentForm } from "./comment-form";
import { CommentThread } from "./comment-thread"; import { CommentThread } from "./comment-thread";
import { SignIn } from "./sign-in"; import { SignIn } from "./sign-in";
import { auth } from "@/lib/auth";
import { getComments, type CommentWithUser } from "@/lib/server/comments";
const Comments = async ({ slug }: { slug: string }) => { const Comments = async ({ slug }: { slug: string }) => {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -21,18 +21,20 @@ const Comments = async ({ slug }: { slug: string }) => {
acc[parentId].push(comment); acc[parentId].push(comment);
return acc; return acc;
}, },
{} as Record<string, CommentWithUser[]> {} as Record<string, CommentWithUser[]>,
); );
const rootComments = commentsByParentId["root"] || []; const rootComments = commentsByParentId.root || [];
return ( return (
<> <>
{session ? ( {session ? (
<NewCommentForm slug={slug} /> <NewCommentForm slug={slug} />
) : ( ) : (
<div className="bg-muted/40 flex flex-col items-center justify-center gap-y-4 rounded-lg p-6"> <div className="flex flex-col items-center justify-center gap-y-4 rounded-lg bg-muted/40 p-6">
<p className="text-center font-medium">Join the discussion by signing in:</p> <p className="text-center font-medium">
Join the discussion by signing in:
</p>
<SignIn callbackPath={`/${slug}#comments`} /> <SignIn callbackPath={`/${slug}#comments`} />
</div> </div>
)} )}
@@ -49,7 +51,7 @@ const Comments = async ({ slug }: { slug: string }) => {
))} ))}
</div> </div>
) : ( ) : (
<div className="text-foreground/80 py-8 text-center text-lg font-medium tracking-tight"> <div className="py-8 text-center font-medium text-foreground/80 text-lg tracking-tight">
Be the first to comment! Be the first to comment!
</div> </div>
)} )}
+9 -4
View File
@@ -1,12 +1,12 @@
"use client"; "use client";
import { env } from "@/lib/env"; import { Loader2Icon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { GitHubIcon } from "@/components/icons"; import { GitHubIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { signIn } from "@/lib/auth-client"; import { signIn } from "@/lib/auth-client";
import { env } from "@/lib/env";
const SignIn = ({ callbackPath }: { callbackPath?: string }) => { const SignIn = ({ callbackPath }: { callbackPath?: string }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -27,7 +27,12 @@ const SignIn = ({ callbackPath }: { callbackPath?: string }) => {
}; };
return ( return (
<Button onClick={handleSignIn} disabled={isLoading} size="lg" variant="outline"> <Button
onClick={handleSignIn}
disabled={isLoading}
size="lg"
variant="outline"
>
{isLoading ? <Loader2Icon className="animate-spin" /> : <GitHubIcon />} {isLoading ? <Loader2Icon className="animate-spin" /> : <GitHubIcon />}
Sign in with GitHub Sign in with GitHub
</Button> </Button>
+58 -24
View File
@@ -1,16 +1,16 @@
"use client"; "use client";
import { useState } from "react";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react"; import { CheckIcon, Loader2Icon, SendIcon, XIcon } from "lucide-react";
import { useState } from "react";
import { MarkdownIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
import { MarkdownIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import { sendContactForm, type ContactResult } from "@/lib/server/contact";
import { ContactSchema } from "@/lib/schemas/contact"; import { ContactSchema } from "@/lib/schemas/contact";
import { type ContactResult, sendContactForm } from "@/lib/server/contact";
import { cn } from "@/lib/utils";
const ContactForm = () => { const ContactForm = () => {
const [result, setResult] = useState<ContactResult | null>(null); const [result, setResult] = useState<ContactResult | null>(null);
@@ -55,14 +55,21 @@ const ContactForm = () => {
}} }}
className="my-5 space-y-4" className="my-5 space-y-4"
> >
<form.Subscribe selector={(state) => state.isSubmitting || result?.success}> <form.Subscribe
selector={(state) => state.isSubmitting || result?.success}
>
{(isDisabled) => ( {(isDisabled) => (
<> <>
<form.Field name="name"> <form.Field name="name">
{(field) => { {(field) => {
const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0; const isInvalid =
field.state.meta.isTouched &&
field.state.meta.errors.length > 0;
return ( return (
<Field data-invalid={isInvalid || undefined} className="gap-1.5"> <Field
data-invalid={isInvalid || undefined}
className="gap-1.5"
>
<FieldLabel htmlFor="name">Name</FieldLabel> <FieldLabel htmlFor="name">Name</FieldLabel>
<Input <Input
id="name" id="name"
@@ -76,7 +83,9 @@ const ContactForm = () => {
disabled={!!isDisabled} disabled={!!isDisabled}
aria-invalid={isInvalid || undefined} aria-invalid={isInvalid || undefined}
/> />
{isInvalid && <FieldError errors={field.state.meta.errors} />} {isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field> </Field>
); );
}} }}
@@ -84,9 +93,14 @@ const ContactForm = () => {
<form.Field name="email"> <form.Field name="email">
{(field) => { {(field) => {
const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0; const isInvalid =
field.state.meta.isTouched &&
field.state.meta.errors.length > 0;
return ( return (
<Field data-invalid={isInvalid || undefined} className="gap-1.5"> <Field
data-invalid={isInvalid || undefined}
className="gap-1.5"
>
<FieldLabel htmlFor="email">Email</FieldLabel> <FieldLabel htmlFor="email">Email</FieldLabel>
<Input <Input
id="email" id="email"
@@ -102,7 +116,9 @@ const ContactForm = () => {
disabled={!!isDisabled} disabled={!!isDisabled}
aria-invalid={isInvalid || undefined} aria-invalid={isInvalid || undefined}
/> />
{isInvalid && <FieldError errors={field.state.meta.errors} />} {isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field> </Field>
); );
}} }}
@@ -110,9 +126,14 @@ const ContactForm = () => {
<form.Field name="message"> <form.Field name="message">
{(field) => { {(field) => {
const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0; const isInvalid =
field.state.meta.isTouched &&
field.state.meta.errors.length > 0;
return ( return (
<Field data-invalid={isInvalid || undefined} className="gap-1.5"> <Field
data-invalid={isInvalid || undefined}
className="gap-1.5"
>
<FieldLabel htmlFor="message">Message</FieldLabel> <FieldLabel htmlFor="message">Message</FieldLabel>
<Textarea <Textarea
id="message" id="message"
@@ -125,9 +146,11 @@ const ContactForm = () => {
aria-invalid={isInvalid || undefined} aria-invalid={isInvalid || undefined}
className="min-h-[6lh] resize-y" className="min-h-[6lh] resize-y"
/> />
{isInvalid && <FieldError errors={field.state.meta.errors} />} {isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
<p className="text-foreground/85 mt-1.5 text-[0.8rem] leading-relaxed"> <p className="mt-1.5 text-[0.8rem] text-foreground/85 leading-relaxed">
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" /> <MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" />
Basic{" "} Basic{" "}
<a <a
@@ -139,8 +162,14 @@ const ContactForm = () => {
> >
Markdown syntax Markdown syntax
</a>{" "} </a>{" "}
is allowed, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [ is allowed, e.g.: <strong>**bold**</strong>,{" "}
<a href="https://jarv.is" target="_blank" rel="noopener" className="hover:no-underline"> <em>_italics_</em>, [
<a
href="https://jarv.is"
target="_blank"
rel="noopener"
className="hover:no-underline"
>
links links
</a> </a>
](https://jarv.is), and <code>`code`</code>. ](https://jarv.is), and <code>`code`</code>.
@@ -154,14 +183,17 @@ const ContactForm = () => {
</form.Subscribe> </form.Subscribe>
<div className="flex min-h-16 items-center space-x-4"> <div className="flex min-h-16 items-center space-x-4">
<form.Subscribe selector={(state) => [, state.isSubmitting]}> <form.Subscribe selector={(state) => [undefined, state.isSubmitting]}>
{([isSubmitting]) => ( {([isSubmitting]) => (
<> <>
{!result?.success && ( {!result?.success && (
<Button type="submit" size="lg" disabled={isSubmitting}> <Button type="submit" size="lg" disabled={isSubmitting}>
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2Icon className="animate-spin" aria-hidden="true" /> <Loader2Icon
className="animate-spin"
aria-hidden="true"
/>
Sending Sending
</> </>
) : ( ) : (
@@ -178,8 +210,10 @@ const ContactForm = () => {
role="status" role="status"
aria-live="polite" aria-live="polite"
className={cn( className={cn(
"space-x-0.5 text-[0.9rem] font-semibold", "space-x-0.5 font-semibold text-[0.9rem]",
result.success ? "text-green-600 dark:text-green-400" : "text-destructive" result.success
? "text-green-600 dark:text-green-400"
: "text-destructive",
)} )}
> >
{result.success ? ( {result.success ? (
+19 -10
View File
@@ -1,11 +1,11 @@
"use client"; "use client";
import * as React from "react";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CheckIcon, ClipboardCheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, ClipboardCheckIcon, CopyIcon } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner";
function CopyButton({ function CopyButton({
value, value,
@@ -18,13 +18,14 @@ function CopyButton({
const [hasCopied, setHasCopied] = React.useState(false); const [hasCopied, setHasCopied] = React.useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined); const timeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
React.useEffect(() => { React.useEffect(
return () => { () => () => {
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
}; },
}, []); [],
);
const handleCopy = () => { const handleCopy = () => {
if (hasCopied) return; if (hasCopied) return;
@@ -32,7 +33,12 @@ function CopyButton({
copy(value); copy(value);
setHasCopied(true); setHasCopied(true);
toast.success("Copied!", { toast.success("Copied!", {
icon: <ClipboardCheckIcon className="text-foreground/85 size-4" aria-hidden="true" />, icon: (
<ClipboardCheckIcon
className="size-4 text-foreground/85"
aria-hidden="true"
/>
),
duration: 2000, duration: 2000,
id: "copy-button-toast-success", id: "copy-button-toast-success",
}); });
@@ -50,16 +56,19 @@ function CopyButton({
size="icon" size="icon"
variant={variant} variant={variant}
className={cn( className={cn(
"bg-code hover:bg-accent dark:hover:bg-accent absolute top-3 right-2 z-10 size-7.5 hover:opacity-100 focus-visible:opacity-100", "absolute top-3 right-2 z-10 size-7.5 bg-code hover:bg-accent hover:opacity-100 focus-visible:opacity-100 dark:hover:bg-accent",
hasCopied ? "cursor-default" : "cursor-pointer", hasCopied ? "cursor-default" : "cursor-pointer",
className className,
)} )}
onClick={handleCopy} onClick={handleCopy}
aria-label={hasCopied ? "Copied" : "Copy to clipboard"} aria-label={hasCopied ? "Copied" : "Copy to clipboard"}
{...props} {...props}
> >
{hasCopied ? ( {hasCopied ? (
<CheckIcon className="text-green-600 dark:text-green-400" aria-hidden="true" /> <CheckIcon
className="text-green-600 dark:text-green-400"
aria-hidden="true"
/>
) : ( ) : (
<CopyIcon aria-hidden="true" /> <CopyIcon aria-hidden="true" />
)} )}
+11 -5
View File
@@ -1,13 +1,20 @@
import { LinkIcon } from "lucide-react"; import { LinkIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const HeadingAnchor = ({ id, title, className }: { id: string; title: string; className?: string }) => { const HeadingAnchor = ({
return ( id,
title,
className,
}: {
id: string;
title: string;
className?: string;
}) => (
<a <a
href={`#${id}`} href={`#${id}`}
className={cn( className={cn(
"text-muted-foreground hover:text-primary ml-2 inline-block px-2 align-baseline hover:no-underline", "ml-2 inline-block px-2 align-baseline text-muted-foreground hover:text-primary hover:no-underline",
className className,
)} )}
aria-hidden="true" aria-hidden="true"
tabIndex={-1} tabIndex={-1}
@@ -16,6 +23,5 @@ const HeadingAnchor = ({ id, title, className }: { id: string; title: string; cl
<span className="sr-only">Permalink to &ldquo;{title}&rdquo;</span> <span className="sr-only">Permalink to &ldquo;{title}&rdquo;</span>
</a> </a>
); );
};
export { HeadingAnchor }; export { HeadingAnchor };
+37 -11
View File
@@ -1,21 +1,33 @@
"use client"; "use client";
import { Children } from "react";
import { getImageProps } from "next/image"; import { getImageProps } from "next/image";
import { ReactCompareSlider, ReactCompareSliderImage } from "react-compare-slider"; import { Children } from "react";
import {
ReactCompareSlider,
ReactCompareSliderImage,
} from "react-compare-slider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const ImageDiff = ({ children, className }: { children: React.ReactElement[]; className?: string }) => { const ImageDiff = ({
children,
className,
}: {
children: React.ReactElement[];
className?: string;
}) => {
// Extract the two image children // Extract the two image children
const childrenArray = Children.toArray(children); const childrenArray = Children.toArray(children);
if (childrenArray.length !== 2) { if (childrenArray.length !== 2) {
console.error("ImageDiff must have exactly two children (before and after images)"); console.error(
"ImageDiff must have exactly two children (before and after images)",
);
return null; return null;
} }
// Get the original image source to extract dimensions for aspect ratio // Get the original image source to extract dimensions for aspect ratio
// eslint-disable-next-line @typescript-eslint/no-explicit-any const firstChildProps = children[0].props as Parameters<
const firstChildProps = children[0].props as any; typeof getImageProps
>[0];
const imageSrc = firstChildProps.src; const imageSrc = firstChildProps.src;
const aspectRatio = const aspectRatio =
typeof imageSrc === "object" && "width" in imageSrc && "height" in imageSrc typeof imageSrc === "object" && "width" in imageSrc && "height" in imageSrc
@@ -24,15 +36,29 @@ const ImageDiff = ({ children, className }: { children: React.ReactElement[]; cl
// Extract image props, stripping out MDX className (margins, etc.) that would break slider layout // Extract image props, stripping out MDX className (margins, etc.) that would break slider layout
const beforeImageProps = getImageProps(firstChildProps).props; const beforeImageProps = getImageProps(firstChildProps).props;
// eslint-disable-next-line @typescript-eslint/no-explicit-any const afterImageProps = getImageProps(
const afterImageProps = getImageProps(children[1].props as any).props; children[1].props as Parameters<typeof getImageProps>[0],
).props;
return ( return (
<ReactCompareSlider <ReactCompareSlider
className={cn("my-8 w-full max-w-full overflow-hidden rounded-sm", className)} className={cn(
"my-8 w-full max-w-full overflow-hidden rounded-sm",
className,
)}
style={{ aspectRatio }} style={{ aspectRatio }}
itemOne={<ReactCompareSliderImage {...beforeImageProps} className="size-full object-cover" />} itemOne={
itemTwo={<ReactCompareSliderImage {...afterImageProps} className="size-full object-cover" />} <ReactCompareSliderImage
{...beforeImageProps}
className="size-full object-cover"
/>
}
itemTwo={
<ReactCompareSliderImage
{...afterImageProps}
className="size-full object-cover"
/>
}
/> />
); );
}; };
+3 -5
View File
@@ -1,10 +1,9 @@
import { env } from "@/lib/env";
import Link from "next/link"; import Link from "next/link";
import siteConfig from "@/lib/config/site"; import siteConfig from "@/lib/config/site";
import { env } from "@/lib/env";
const Footer = () => { const Footer = () => (
return ( <footer className="mt-8 w-full py-6 text-center text-[13px] text-muted-foreground leading-loose">
<footer className="text-muted-foreground mt-8 w-full py-6 text-center text-[13px] leading-loose">
All content is licensed under{" "} All content is licensed under{" "}
<Link href="/license" className="underline underline-offset-4"> <Link href="/license" className="underline underline-offset-4">
{siteConfig.license} {siteConfig.license}
@@ -21,6 +20,5 @@ const Footer = () => {
. .
</footer> </footer>
); );
};
export { Footer }; export { Footer };
+30 -16
View File
@@ -1,19 +1,18 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import avatarImg from "@/app/avatar.jpg";
import { GitHubIcon } from "@/components/icons";
import { Menu } from "@/components/layout/menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Menu } from "@/components/layout/menu";
import { GitHubIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import authorConfig from "@/lib/config/author"; import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site"; import siteConfig from "@/lib/config/site";
import { MoonIcon, SunIcon } from "lucide-react"; import { cn } from "@/lib/utils";
import avatarImg from "@/app/avatar.jpg";
const Header = ({ className }: { className?: string }) => { const Header = ({ className }: { className?: string }) => {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
@@ -40,7 +39,7 @@ const Header = ({ className }: { className?: string }) => {
"bg-background/0 backdrop-blur-none", "bg-background/0 backdrop-blur-none",
"data-[scrolled=true]:bg-background/80 data-[scrolled=true]:backdrop-blur-md", "data-[scrolled=true]:bg-background/80 data-[scrolled=true]:backdrop-blur-md",
"data-[scrolled=true]:border-border/50 data-[scrolled=true]:border-b", "data-[scrolled=true]:border-border/50 data-[scrolled=true]:border-b",
className className,
)} )}
> >
<header className="mx-auto flex w-full max-w-4xl items-center justify-between px-5 py-4"> <header className="mx-auto flex w-full max-w-4xl items-center justify-between px-5 py-4">
@@ -49,18 +48,18 @@ const Header = ({ className }: { className?: string }) => {
href="/" href="/"
rel="author" rel="author"
aria-label={siteConfig.name} aria-label={siteConfig.name}
className="hover:text-foreground/85 flex shrink-0 items-center gap-2.5 pr-2 hover:no-underline" className="flex shrink-0 items-center gap-2.5 pr-2 hover:text-foreground/85 hover:no-underline"
> >
<Image <Image
src={avatarImg} src={avatarImg}
alt={`Photo of ${siteConfig.name}`} alt={`Photo of ${siteConfig.name}`}
className="border-ring/30 size-[40px] rounded-full border md:size-[32px]" className="size-[40px] rounded-full border border-ring/30 md:size-[32px]"
width={40} width={40}
height={40} height={40}
quality={75} quality={75}
priority priority
/> />
<span className="text-[17.5px] font-medium tracking-tight whitespace-nowrap max-md:sr-only"> <span className="whitespace-nowrap font-medium text-[17.5px] tracking-tight max-md:sr-only">
{siteConfig.name} {siteConfig.name}
</span> </span>
</Link> </Link>
@@ -69,8 +68,17 @@ const Header = ({ className }: { className?: string }) => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" aria-label="Open GitHub profile" asChild> <Button
<a href={`https://github.com/${authorConfig.social.github}`} target="_blank" rel="noopener noreferrer"> variant="ghost"
size="sm"
aria-label="Open GitHub profile"
asChild
>
<a
href={`https://github.com/${authorConfig.social.github}`}
target="_blank"
rel="noopener noreferrer"
>
<GitHubIcon /> <GitHubIcon />
</a> </a>
</Button> </Button>
@@ -81,8 +89,14 @@ const Header = ({ className }: { className?: string }) => {
aria-label="Toggle theme" aria-label="Toggle theme"
className="group" className="group"
> >
<SunIcon className="group-hover:stroke-orange-600 dark:hidden" aria-hidden="true" /> <SunIcon
<MoonIcon className="not-dark:hidden group-hover:stroke-yellow-400" aria-hidden="true" /> className="group-hover:stroke-orange-600 dark:hidden"
aria-hidden="true"
/>
<MoonIcon
className="not-dark:hidden group-hover:stroke-yellow-400"
aria-hidden="true"
/>
</Button> </Button>
</div> </div>
</header> </header>
+16 -10
View File
@@ -1,14 +1,14 @@
"use client"; "use client";
import { useSelectedLayoutSegment } from "next/navigation";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
const menuItems = [ const menuItems = [
@@ -29,25 +29,27 @@ const menuItems = [
const Menu = () => { const Menu = () => {
const segment = useSelectedLayoutSegment() || ""; const segment = useSelectedLayoutSegment() || "";
const currentItem = menuItems.find((item) => item.href?.split("/")[1] === segment); const currentItem = menuItems.find(
(item) => item.href?.split("/")[1] === segment,
);
const currentLabel = segment === "" ? "Home" : currentItem?.text || "Menu"; const currentLabel = segment === "" ? "Home" : currentItem?.text || "Menu";
return ( return (
<nav data-slot="navigation-menu"> <nav data-slot="navigation-menu">
{/* Desktop: Show all buttons */} {/* Desktop: Show all buttons */}
<div className="hidden items-center gap-1.5 sm:flex"> <div className="hidden items-center gap-1.5 sm:flex">
{menuItems.map((item, index) => { {menuItems.map((item) => {
const isCurrent = item.href?.split("/")[1] === segment; const isCurrent = item.href?.split("/")[1] === segment;
return ( return (
<Button <Button
asChild asChild
key={index} key={item.href}
variant="ghost" variant="ghost"
size="sm" size="sm"
aria-label={item.text} aria-label={item.text}
data-current={isCurrent || undefined} data-current={isCurrent || undefined}
className="data-current:bg-accent/60 data-current:text-accent-foreground text-[15px] leading-none" className="text-[15px] leading-none data-current:bg-accent/60 data-current:text-accent-foreground"
> >
<Link href={item.href}>{item.text}</Link> <Link href={item.href}>{item.text}</Link>
</Button> </Button>
@@ -68,16 +70,20 @@ const Menu = () => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[140px]"> <DropdownMenuContent align="start" className="min-w-[140px]">
<DropdownMenuItem asChild data-current={segment === ""} aria-current={segment === "" ? "page" : undefined}> <DropdownMenuItem
asChild
data-current={segment === ""}
aria-current={segment === "" ? "page" : undefined}
>
<Link href="/">Home</Link> <Link href="/">Home</Link>
</DropdownMenuItem> </DropdownMenuItem>
{menuItems.map((item, index) => { {menuItems.map((item) => {
const isCurrent = item.href?.split("/")[1] === segment; const isCurrent = item.href?.split("/")[1] === segment;
return ( return (
<DropdownMenuItem <DropdownMenuItem
asChild asChild
key={index} key={item.href}
data-current={isCurrent || undefined} data-current={isCurrent || undefined}
aria-current={isCurrent ? "page" : undefined} aria-current={isCurrent ? "page" : undefined}
> >
+6 -5
View File
@@ -8,20 +8,21 @@ const PageTitle = ({
...rest ...rest
}: React.ComponentProps<"h1"> & { }: React.ComponentProps<"h1"> & {
canonical: string; canonical: string;
}) => { }) => (
return (
<h1 <h1
className={cn("not-prose mt-0 mb-6 text-left text-3xl font-medium tracking-tight lowercase", className)} className={cn(
"not-prose mt-0 mb-6 text-left font-medium text-3xl lowercase tracking-tight",
className,
)}
{...rest} {...rest}
> >
<Link <Link
href={canonical} href={canonical}
className="before:text-muted-foreground text-foreground no-underline before:mr-[-3px] before:tracking-wider before:content-['\002E\002F']" className="text-foreground no-underline before:mr-[-3px] before:text-muted-foreground before:tracking-wider before:content-['\002E\002F']"
> >
{children} {children}
</Link> </Link>
</h1> </h1>
); );
};
export { PageTitle }; export { PageTitle };
+13 -5
View File
@@ -10,18 +10,26 @@ const Marquee = ({
reverse?: boolean; reverse?: boolean;
pauseOnHover?: boolean; pauseOnHover?: boolean;
repeat?: number; repeat?: number;
}) => { }) => (
return ( <div
<div className={cn("group flex flex-row [gap:var(--gap)] overflow-hidden [--gap:2rem]", className)} {...rest}> className={cn(
"group flex flex-row overflow-hidden [--gap:2rem] [gap:var(--gap)]",
className,
)}
{...rest}
>
{Array(repeat) {Array(repeat)
.fill(0) .fill(0)
.map((_, i) => ( .map((_, i) => (
<div key={i} className="motion-safe:animate-marquee flex shrink-0 flex-row justify-around [gap:var(--gap)]"> <div
// biome-ignore lint/suspicious/noArrayIndexKey: identical clones for animation; no natural unique key exists
key={i}
className="flex shrink-0 flex-row justify-around [gap:var(--gap)] motion-safe:animate-marquee"
>
{children} {children}
</div> </div>
))} ))}
</div> </div>
); );
};
export { Marquee }; export { Marquee };
+35 -9
View File
@@ -1,13 +1,19 @@
"use client"; "use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import Link from "next/link";
import { EyeIcon, MessagesSquareIcon } from "lucide-react"; import { EyeIcon, MessagesSquareIcon } from "lucide-react";
import Link from "next/link";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { getAllViewCounts } from "@/lib/server/views";
import { getAllCommentCounts } from "@/lib/server/comments"; import { getAllCommentCounts } from "@/lib/server/comments";
import { getAllViewCounts } from "@/lib/server/views";
const numberFormatter = new Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE); const numberFormatter = new Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE);
@@ -17,14 +23,22 @@ type Stats = {
loaded: boolean; loaded: boolean;
}; };
const StatsContext = createContext<Stats>({ views: {}, comments: {}, loaded: false }); const StatsContext = createContext<Stats>({
views: {},
comments: {},
loaded: false,
});
/** /**
* Provider that fetches ALL post stats in a single batch (2 requests total). * Provider that fetches ALL post stats in a single batch (2 requests total).
* Wrap this around any component tree that contains PostStats components. * Wrap this around any component tree that contains PostStats components.
*/ */
export const PostStatsProvider = ({ children }: { children: ReactNode }) => { export const PostStatsProvider = ({ children }: { children: ReactNode }) => {
const [stats, setStats] = useState<Stats>({ views: {}, comments: {}, loaded: false }); const [stats, setStats] = useState<Stats>({
views: {},
comments: {},
loaded: false,
});
useEffect(() => { useEffect(() => {
Promise.all([getAllViewCounts(), getAllCommentCounts()]) Promise.all([getAllViewCounts(), getAllCommentCounts()])
@@ -37,7 +51,9 @@ export const PostStatsProvider = ({ children }: { children: ReactNode }) => {
}); });
}, []); }, []);
return <StatsContext.Provider value={stats}>{children}</StatsContext.Provider>; return (
<StatsContext.Provider value={stats}>{children}</StatsContext.Provider>
);
}; };
/** /**
@@ -62,19 +78,29 @@ const PostStats = ({ slug }: { slug: string }) => {
return ( return (
<> <>
{viewCount > 0 && ( {viewCount > 0 && (
<Badge variant="secondary" className="text-foreground/80 gap-[5px] tabular-nums"> <Badge
variant="secondary"
className="gap-[5px] text-foreground/80 tabular-nums"
>
<EyeIcon className="text-foreground/65" aria-hidden="true" /> <EyeIcon className="text-foreground/65" aria-hidden="true" />
{numberFormatter.format(viewCount)} {numberFormatter.format(viewCount)}
</Badge> </Badge>
)} )}
{commentCount > 0 && ( {commentCount > 0 && (
<Badge variant="secondary" className="text-foreground/80 gap-[5px] tabular-nums" asChild> <Badge
variant="secondary"
className="gap-[5px] text-foreground/80 tabular-nums"
asChild
>
<Link <Link
href={`/${slug}#comments`} href={`/${slug}#comments`}
title={`${numberFormatter.format(commentCount)} ${commentCount === 1 ? "comment" : "comments"}`} title={`${numberFormatter.format(commentCount)} ${commentCount === 1 ? "comment" : "comments"}`}
> >
<MessagesSquareIcon className="text-foreground/65" aria-hidden="true" /> <MessagesSquareIcon
className="text-foreground/65"
aria-hidden="true"
/>
{numberFormatter.format(commentCount)} {numberFormatter.format(commentCount)}
</Link> </Link>
</Badge> </Badge>
+7 -4
View File
@@ -2,12 +2,15 @@
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
const Providers = ({ children }: { children: React.ReactNode }) => { const Providers = ({ children }: { children: React.ReactNode }) => (
return ( <ThemeProvider
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange> attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children} {children}
</ThemeProvider> </ThemeProvider>
); );
};
export { Providers }; export { Providers };
+1 -3
View File
@@ -9,12 +9,10 @@ const intlFormatter = makeIntlFormatter({
numeric: "auto", numeric: "auto",
}); });
const RelativeTime = ({ ...rest }: React.ComponentProps<typeof TimeAgo>) => { const RelativeTime = ({ ...rest }: React.ComponentProps<typeof TimeAgo>) => (
return (
<span suppressHydrationWarning> <span suppressHydrationWarning>
<TimeAgo formatter={intlFormatter} {...rest} /> <TimeAgo formatter={intlFormatter} {...rest} />
</span> </span>
); );
};
export { RelativeTime }; export { RelativeTime };
+1 -3
View File
@@ -16,8 +16,7 @@ const CodePen = ({
preview?: boolean; preview?: boolean;
editable?: boolean; editable?: boolean;
title?: string; title?: string;
} & React.ComponentProps<"iframe">) => { } & React.ComponentProps<"iframe">) => (
return (
<iframe <iframe
src={`https://codepen.io/${username}/embed/${id}/?${new URLSearchParams({ src={`https://codepen.io/${username}/embed/${id}/?${new URLSearchParams({
"default-tab": `${defaultTab},result`, "default-tab": `${defaultTab},result`,
@@ -29,6 +28,5 @@ const CodePen = ({
{...rest} {...rest}
/> />
); );
};
export { CodePen }; export { CodePen };
+5 -1
View File
@@ -7,7 +7,11 @@ const Gist = async ({
title, title,
className, className,
...rest ...rest
}: { id: string; file?: string; title?: string } & React.ComponentProps<"iframe">) => { }: {
id: string;
file?: string;
title?: string;
} & React.ComponentProps<"iframe">) => {
"use cache"; "use cache";
cacheLife("max"); cacheLife("max");
cacheTag("gist", `gist-${id}${file ? `-${file}` : ""}`); cacheTag("gist", `gist-${id}${file ? `-${file}` : ""}`);
+2 -2
View File
@@ -1,7 +1,7 @@
import { cacheLife, cacheTag } from "next/cache"; import { cacheLife, cacheTag } from "next/cache";
import Image from "next/image"; import Image from "next/image";
import type { Tweet as TweetType } from "react-tweet/api";
import { EmbeddedTweet, TweetNotFound } from "react-tweet"; import { EmbeddedTweet, TweetNotFound } from "react-tweet";
import type { Tweet as TweetType } from "react-tweet/api";
import { fetchTweet } from "react-tweet/api"; import { fetchTweet } from "react-tweet/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -32,7 +32,7 @@ const Tweet = async ({ id, className }: { id: string; className?: string }) => {
className={cn( className={cn(
"my-6 min-h-30", "my-6 min-h-30",
"*:[--tweet-body-font-size:var(--text-base)]! *:[--tweet-body-line-height:var(--leading-normal)]! *:[--tweet-container-margin:0_auto]! *:[--tweet-font-family:var(--font-sans)]! *:[--tweet-info-font-size:var(--text-sm)]! *:[--tweet-info-line-height:var(--leading-normal)]!", "*:[--tweet-body-font-size:var(--text-base)]! *:[--tweet-body-line-height:var(--leading-normal)]! *:[--tweet-container-margin:0_auto]! *:[--tweet-font-family:var(--font-sans)]! *:[--tweet-info-font-size:var(--text-sm)]! *:[--tweet-info-line-height:var(--leading-normal)]!",
className className,
)} )}
> >
<EmbeddedTweet <EmbeddedTweet
+6 -3
View File
@@ -4,8 +4,11 @@ import YouTubeEmbed from "react-lite-youtube-embed";
// lite-youtube-embed CSS is imported in app/global.css to save a request // lite-youtube-embed CSS is imported in app/global.css to save a request
const YouTube = ({ title = "YouTube video", ...rest }: React.ComponentProps<typeof YouTubeEmbed>) => { const YouTube = ({
return <YouTubeEmbed cookie={false} containerElement="div" title={title} {...rest} />; title = "YouTube video",
}; ...rest
}: React.ComponentProps<typeof YouTubeEmbed>) => (
<YouTubeEmbed cookie={false} containerElement="div" title={title} {...rest} />
);
export { YouTube }; export { YouTube };
+22 -9
View File
@@ -1,16 +1,21 @@
"use client"; "use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion"; import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) { function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />; return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
} }
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) { function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return ( return (
<AccordionPrimitive.Item <AccordionPrimitive.Item
data-slot="accordion-item" data-slot="accordion-item"
@@ -20,29 +25,37 @@ function AccordionItem({ className, ...props }: React.ComponentProps<typeof Acco
); );
} }
function AccordionTrigger({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) { function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return ( return (
<AccordionPrimitive.Header className="flex"> <AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
data-slot="accordion-trigger" data-slot="accordion-trigger"
className={cn( className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180", "flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" /> <ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
); );
} }
function AccordionContent({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Content>) { function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return ( return (
<AccordionPrimitive.Content <AccordionPrimitive.Content
data-slot="accordion-content" data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm" className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props} {...props}
> >
<div className={cn("pt-0 pb-4", className)}>{children}</div> <div className={cn("pt-0 pb-4", className)}>{children}</div>
+57 -25
View File
@@ -1,30 +1,42 @@
"use client"; "use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />; return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
} }
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { function AlertDialogTrigger({
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />; ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
} }
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { function AlertDialogPortal({
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />; ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
} }
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) { function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return ( return (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className className,
)} )}
{...props} {...props}
/> />
@@ -45,8 +57,8 @@ function AlertDialogContent({
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
data-size={size} data-size={size}
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=open]:animate-in data-[size=default]:sm:max-w-lg",
className className,
)} )}
{...props} {...props}
/> />
@@ -54,39 +66,48 @@ function AlertDialogContent({
); );
} }
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-header" data-slot="alert-dialog-header"
className={cn( className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return ( return (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
className={cn( className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", "font-semibold text-lg sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className className,
)} )}
{...props} {...props}
/> />
@@ -106,13 +127,16 @@ function AlertDialogDescription({
); );
} }
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-media" data-slot="alert-dialog-media"
className={cn( className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8", "mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className className,
)} )}
{...props} {...props}
/> />
@@ -128,7 +152,11 @@ function AlertDialogAction({
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return ( return (
<Button variant={variant} size={size} asChild> <Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} /> <AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button> </Button>
); );
} }
@@ -142,7 +170,11 @@ function AlertDialogCancel({
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return ( return (
<Button variant={variant} size={size} asChild> <Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} /> <AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button> </Button>
); );
} }
+26 -9
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -10,36 +10,53 @@ const alertVariants = cva(
variant: { variant: {
default: "bg-card text-card-foreground", default: "bg-card text-card-foreground",
destructive: destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current", "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
); );
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { function Alert({
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />; className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
} }
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-title" data-slot="alert-title"
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)} className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props} {...props}
/> />
); );
} }
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-description" data-slot="alert-description"
className={cn( className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", "col-start-2 grid justify-items-start gap-1 text-muted-foreground text-sm [&_p]:leading-relaxed",
className className,
)} )}
{...props} {...props}
/> />
+3 -1
View File
@@ -2,7 +2,9 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({ ...props }: React.ComponentProps<typeof AspectRatioPrimitive.Root>) { function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />; return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
} }
+36 -16
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar"; import * as AvatarPrimitive from "@radix-ui/react-avatar";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -17,27 +17,37 @@ function Avatar({
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
className={cn( className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6", "group/avatar relative flex size-8 shrink-0 select-none overflow-hidden rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) { function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return ( return (
<AvatarPrimitive.Image data-slot="avatar-image" className={cn("aspect-square size-full", className)} {...props} /> <AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
); );
} }
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return ( return (
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
className={cn( className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs", "flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm group-data-[size=sm]/avatar:text-xs",
className className,
)} )}
{...props} {...props}
/> />
@@ -49,11 +59,11 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
<span <span
data-slot="avatar-badge" data-slot="avatar-badge"
className={cn( className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none", "absolute right-0 bottom-0 z-10 inline-flex select-none items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className className,
)} )}
{...props} {...props}
/> />
@@ -65,25 +75,35 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="avatar-group" data-slot="avatar-group"
className={cn( className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2", "group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="avatar-group-count" data-slot="avatar-group-count"
className={cn( className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount }; export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
};
+16 -8
View File
@@ -1,19 +1,21 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3", "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white", "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline", link: "text-primary underline-offset-4 [a&]:hover:underline",
}, },
@@ -21,7 +23,7 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
); );
function Badge({ function Badge({
@@ -29,11 +31,17 @@ function Badge({
variant = "default", variant = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) { }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"; const Comp = asChild ? Slot : "span";
return ( return (
<Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} /> <Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
); );
} }
+14 -7
View File
@@ -1,8 +1,7 @@
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const buttonGroupVariants = cva( const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
@@ -18,7 +17,7 @@ const buttonGroupVariants = cva(
defaultVariants: { defaultVariants: {
orientation: "horizontal", orientation: "horizontal",
}, },
} },
); );
function ButtonGroup({ function ButtonGroup({
@@ -49,8 +48,8 @@ function ButtonGroupText({
return ( return (
<Comp <Comp
className={cn( className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", "flex items-center gap-2 rounded-md border bg-muted px-4 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className className,
)} )}
{...props} {...props}
/> />
@@ -66,10 +65,18 @@ function ButtonGroupSeparator({
<Separator <Separator
data-slot="button-group-separator" data-slot="button-group-separator"
orientation={orientation} orientation={orientation}
className={cn("bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto", className)} className={cn(
"!m-0 relative self-stretch bg-input data-[orientation=vertical]:h-auto",
className,
)}
{...props} {...props}
/> />
); );
} }
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }; export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};
+9 -7
View File
@@ -1,21 +1,23 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline: outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs", "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary:
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
@@ -33,7 +35,7 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
); );
function Button({ function Button({
+45 -9
View File
@@ -1,4 +1,4 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -6,7 +6,10 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className,
)}
{...props} {...props}
/> />
); );
@@ -18,7 +21,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
@@ -26,31 +29,64 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />; return (
<div
data-slot="card-title"
className={cn("font-semibold leading-none", className)}
{...props}
/>
);
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />; return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props} {...props}
/> />
); );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />; return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} /> <div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
); );
} }
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+7 -4
View File
@@ -1,18 +1,21 @@
"use client"; "use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
> >
+21 -5
View File
@@ -2,16 +2,32 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />; return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
} }
function CollapsibleTrigger({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) { function CollapsibleTrigger({
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />; ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
} }
function CollapsibleContent({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) { function CollapsibleContent({
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />; ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent }; export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+36 -17
View File
@@ -1,35 +1,45 @@
"use client"; "use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className className,
)} )}
{...props} {...props}
/> />
@@ -50,8 +60,8 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg outline-none duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@@ -59,7 +69,7 @@ function DialogContent({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
@@ -91,7 +101,10 @@ function DialogFooter({
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props} {...props}
> >
{children} {children}
@@ -104,17 +117,23 @@ function DialogFooter({
); );
} }
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn("font-semibold text-lg leading-none", className)}
{...props} {...props}
/> />
); );
} }
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) { function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
+66 -28
View File
@@ -1,21 +1,34 @@
"use client"; "use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />; return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { function DropdownMenuPortal({
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />; ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
} }
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { function DropdownMenuTrigger({
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />; ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
} }
function DropdownMenuContent({ function DropdownMenuContent({
@@ -29,8 +42,8 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
className className,
)} )}
{...props} {...props}
/> />
@@ -38,8 +51,12 @@ function DropdownMenuContent({
); );
} }
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { function DropdownMenuGroup({
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />; ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
} }
function DropdownMenuItem({ function DropdownMenuItem({
@@ -57,8 +74,8 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className,
)} )}
{...props} {...props}
/> />
@@ -75,8 +92,8 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -91,8 +108,15 @@ function DropdownMenuCheckboxItem({
); );
} }
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { function DropdownMenuRadioGroup({
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />; ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -104,8 +128,8 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className,
)} )}
{...props} {...props}
> >
@@ -130,33 +154,47 @@ function DropdownMenuLabel({
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)} className={cn(
"px-2 py-1.5 font-medium text-sm data-[inset]:pl-8",
className,
)}
{...props} {...props}
/> />
); );
} }
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
); );
} }
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} className={cn(
"ml-auto text-muted-foreground text-xs tracking-widest",
className,
)}
{...props} {...props}
/> />
); );
} }
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />; return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
@@ -173,8 +211,8 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className,
)} )}
{...props} {...props}
> >
@@ -192,8 +230,8 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
className className,
)} )}
{...props} {...props}
/> />
+29 -10
View File
@@ -7,8 +7,8 @@ function Empty({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="empty" data-slot="empty"
className={cn( className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12", "flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12",
className className,
)} )}
{...props} {...props}
/> />
@@ -19,7 +19,10 @@ function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-header" data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)} className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className,
)}
{...props} {...props}
/> />
); );
@@ -31,13 +34,13 @@ const emptyMediaVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
); );
function EmptyMedia({ function EmptyMedia({
@@ -56,7 +59,13 @@ function EmptyMedia({
} }
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="empty-title" className={cn("text-lg font-medium tracking-tight", className)} {...props} />; return (
<div
data-slot="empty-title"
className={cn("font-medium text-lg tracking-tight", className)}
{...props}
/>
);
} }
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
@@ -64,8 +73,8 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
<div <div
data-slot="empty-description" data-slot="empty-description"
className={cn( className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", "text-muted-foreground text-sm/relaxed [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
@@ -76,10 +85,20 @@ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-content" data-slot="empty-content"
className={cn("flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance", className)} className={cn(
"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
className,
)}
{...props} {...props}
/> />
); );
} }
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};
+45 -24
View File
@@ -1,11 +1,10 @@
"use client"; "use client";
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
@@ -14,7 +13,7 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
className={cn( className={cn(
"flex flex-col gap-6", "flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className className,
)} )}
{...props} {...props}
/> />
@@ -30,7 +29,12 @@ function FieldLegend({
<legend <legend
data-slot="field-legend" data-slot="field-legend"
data-variant={variant} data-variant={variant}
className={cn("mb-3 font-medium", "data-[variant=legend]:text-base", "data-[variant=label]:text-sm", className)} className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className,
)}
{...props} {...props}
/> />
); );
@@ -42,14 +46,16 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-group" data-slot="field-group"
className={cn( className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
const fieldVariants = cva("group/field data-[invalid=true]:text-destructive flex w-full gap-3", { const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: { variants: {
orientation: { orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
@@ -59,7 +65,7 @@ const fieldVariants = cva("group/field data-[invalid=true]:text-destructive flex
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
], ],
responsive: [ responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto", "@md/field-group:flex-row flex-col @md/field-group:items-center @md/field-group:[&>*]:w-auto [&>*]:w-full [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto", "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
], ],
@@ -68,7 +74,8 @@ const fieldVariants = cva("group/field data-[invalid=true]:text-destructive flex
defaultVariants: { defaultVariants: {
orientation: "vertical", orientation: "vertical",
}, },
}); },
);
function Field({ function Field({
className, className,
@@ -90,21 +97,27 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-content" data-slot="field-content"
className={cn("group/field-content flex flex-1 flex-col gap-1.5 leading-snug", className)} className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className,
)}
{...props} {...props}
/> />
); );
} }
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) { function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return ( return (
<Label <Label
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", "has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className className,
)} )}
{...props} {...props}
/> />
@@ -116,8 +129,8 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", "flex w-fit items-center gap-2 font-medium text-sm leading-snug group-data-[disabled=true]/field:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
@@ -129,10 +142,10 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance", "font-normal text-muted-foreground text-sm leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5", "nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
@@ -150,13 +163,16 @@ function FieldSeparator({
<div <div
data-slot="field-separator" data-slot="field-separator"
data-content={!!children} data-content={!!children}
className={cn("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", className)} className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props} {...props}
> >
<Separator className="absolute inset-0 top-1/2" /> <Separator className="absolute inset-0 top-1/2" />
{children && ( {children && (
<span <span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2" className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content" data-slot="field-separator-content"
> >
{children} {children}
@@ -183,15 +199,20 @@ function FieldError({
return null; return null;
} }
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]; const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) { if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message; return uniqueErrors[0]?.message;
} }
return ( return (
<ul className="ml-4 flex list-disc flex-col gap-1"> <ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)} {uniqueErrors.map(
(error) =>
error?.message && <li key={error.message}>{error.message}</li>,
)}
</ul> </ul>
); );
}, [children, errors]); }, [children, errors]);
@@ -204,7 +225,7 @@ function FieldError({
<div <div
role="alert" role="alert"
data-slot="field-error" data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)} className={cn("font-normal text-destructive text-sm", className)}
{...props} {...props}
> >
{content} {content}
+49 -23
View File
@@ -1,12 +1,11 @@
"use client"; "use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) { function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@@ -14,7 +13,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none", "group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs outline-none transition-[color,box-shadow] dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto", "h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment. // Variants based on alignment.
@@ -24,12 +23,12 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state. // Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state. // Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
@@ -37,21 +36,24 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
} }
const inputGroupAddonVariants = cva( const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", "flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 font-medium text-muted-foreground text-sm group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
align: { align: {
"inline-start": "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", "inline-start":
"inline-end": "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start": "block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3", "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end": "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3", "block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
}, },
}, },
defaultVariants: { defaultVariants: {
align: "inline-start", align: "inline-start",
}, },
} },
); );
function InputGroupAddon({ function InputGroupAddon({
@@ -71,24 +73,34 @@ function InputGroupAddon({
} }
e.currentTarget.parentElement?.querySelector("input")?.focus(); e.currentTarget.parentElement?.querySelector("input")?.focus();
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.currentTarget.parentElement?.querySelector("input")?.focus();
}
}}
{...props} {...props}
/> />
); );
} }
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", { const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: { variants: {
size: { size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", "icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0", "icon-sm": "size-8 p-0 has-[>svg]:p-0",
}, },
}, },
defaultVariants: { defaultVariants: {
size: "xs", size: "xs",
}, },
}); },
);
function InputGroupButton({ function InputGroupButton({
className, className,
@@ -96,7 +108,8 @@ function InputGroupButton({
variant = "ghost", variant = "ghost",
size = "xs", size = "xs",
...props ...props
}: Omit<React.ComponentProps<typeof Button>, "size"> & VariantProps<typeof inputGroupButtonVariants>) { }: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return ( return (
<Button <Button
type={type} type={type}
@@ -112,38 +125,51 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return ( return (
<span <span
className={cn( className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", "flex items-center gap-2 text-muted-foreground text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) { function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return ( return (
<Input <Input
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent", "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) { function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return ( return (
<Textarea <Textarea
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent", "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea }; export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
+5 -5
View File
@@ -1,4 +1,4 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -8,10 +8,10 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
+53 -19
View File
@@ -1,22 +1,36 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div role="list" data-slot="item-group" className={cn("group/item-group flex flex-col", className)} {...props} /> <div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
); );
} }
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) { function ItemSeparator({
return <Separator data-slot="item-separator" orientation="horizontal" className={cn("my-0", className)} {...props} />; className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
);
} }
const itemVariants = cva( const itemVariants = cva(
"group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors", "group/item flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50",
{ {
variants: { variants: {
variant: { variant: {
@@ -33,7 +47,7 @@ const itemVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
); );
function Item({ function Item({
@@ -42,7 +56,8 @@ function Item({
size = "default", size = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemVariants> & { asChild?: boolean }) { }: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"; const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
@@ -61,14 +76,15 @@ const itemMediaVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: "bg-transparent",
icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4", icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4",
image: "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover", image:
"size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
); );
function ItemMedia({ function ItemMedia({
@@ -90,7 +106,10 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-content" data-slot="item-content"
className={cn("flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)} className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className,
)}
{...props} {...props}
/> />
); );
@@ -100,7 +119,10 @@ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-title" data-slot="item-title"
className={cn("flex w-fit items-center gap-2 text-sm leading-snug font-medium", className)} className={cn(
"flex w-fit items-center gap-2 font-medium text-sm leading-snug",
className,
)}
{...props} {...props}
/> />
); );
@@ -111,9 +133,9 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
<p <p
data-slot="item-description" data-slot="item-description"
className={cn( className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance", "line-clamp-2 text-balance font-normal text-muted-foreground text-sm leading-normal",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
@@ -121,14 +143,23 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
} }
function ItemActions({ className, ...props }: React.ComponentProps<"div">) { function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="item-actions" className={cn("flex items-center gap-2", className)} {...props} />; return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
);
} }
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-header" data-slot="item-header"
className={cn("flex basis-full items-center justify-between gap-2", className)} className={cn(
"flex basis-full items-center justify-between gap-2",
className,
)}
{...props} {...props}
/> />
); );
@@ -138,7 +169,10 @@ function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="item-footer" data-slot="item-footer"
className={cn("flex basis-full items-center justify-between gap-2", className)} className={cn(
"flex basis-full items-center justify-between gap-2",
className,
)}
{...props} {...props}
/> />
); );
+7 -4
View File
@@ -1,17 +1,20 @@
"use client"; "use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import * as LabelPrimitive from "@radix-ui/react-label";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) { function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex select-none items-center gap-2 font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
+46 -11
View File
@@ -1,15 +1,19 @@
"use client"; "use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover"; import * as PopoverPrimitive from "@radix-ui/react-popover";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) { function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />; return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
@@ -26,8 +30,8 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
className className,
)} )}
{...props} {...props}
/> />
@@ -35,20 +39,51 @@ function PopoverContent({
); );
} }
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="popover-header" className={cn("flex flex-col gap-1 text-sm", className)} {...props} />; return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
);
} }
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return <div data-slot="popover-title" className={cn("font-medium", className)} {...props} />; return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
);
} }
function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) { function PopoverDescription({
return <p data-slot="popover-description" className={cn("text-muted-foreground", className)} {...props} />; className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverHeader, PopoverTitle, PopoverDescription }; export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
};
+19 -7
View File
@@ -1,22 +1,34 @@
"use client"; "use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react"; import { CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) { function RadioGroup({
return <RadioGroupPrimitive.Root data-slot="radio-group" className={cn("grid gap-3", className)} {...props} />; className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
} }
function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Item>) { function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return ( return (
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
data-slot="radio-group-item" data-slot="radio-group-item"
className={cn( className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
> >
@@ -24,7 +36,7 @@ function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof Rad
data-slot="radio-group-indicator" data-slot="radio-group-indicator"
className="relative flex items-center justify-center" className="relative flex items-center justify-center"
> >
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" /> <CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
); );
+19 -9
View File
@@ -1,16 +1,24 @@
"use client"; "use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) { function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return ( return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}> <ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
data-slot="scroll-area-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" className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
> >
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
@@ -30,16 +38,18 @@ function ScrollBar({
data-slot="scroll-area-scrollbar" data-slot="scroll-area-scrollbar"
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"flex touch-none p-px transition-colors select-none", "flex touch-none select-none p-px transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent", orientation === "vertical" &&
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent", "h-full w-2.5 border-l border-l-transparent",
className orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)} )}
{...props} {...props}
> >
<ScrollAreaPrimitive.ScrollAreaThumb <ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb" data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full" className="relative flex-1 rounded-full bg-border"
/> />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
); );
+49 -21
View File
@@ -1,20 +1,26 @@
"use client"; "use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />; return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />; return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />; return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
@@ -31,8 +37,8 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-9 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className,
)} )}
{...props} {...props}
> >
@@ -56,10 +62,10 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
align={align} align={align}
@@ -70,7 +76,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
@@ -81,27 +87,37 @@ function SelectContent({
); );
} }
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("px-2 py-1.5 text-muted-foreground text-xs", className)}
{...props} {...props}
/> />
); );
} }
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) { function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
<span data-slot="select-item-indicator" className="absolute right-2 flex size-3.5 items-center justify-center"> <span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
@@ -111,21 +127,30 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
); );
} }
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) { function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
); );
} }
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)} className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
@@ -140,7 +165,10 @@ function SelectScrollDownButton({
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)} className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
+3 -3
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator"; import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -17,8 +17,8 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
+7 -1
View File
@@ -1,7 +1,13 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} />; return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
);
} }
export { Skeleton }; export { Skeleton };
+7 -1
View File
@@ -1,6 +1,12 @@
"use client"; "use client";
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react"; import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner"; import { Toaster as Sonner, type ToasterProps } from "sonner";
+8 -1
View File
@@ -3,7 +3,14 @@ import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) { function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return <Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />; return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
} }
export { Spinner }; export { Spinner };
+4 -4
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch"; import * as SwitchPrimitive from "@radix-ui/react-switch";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -17,15 +17,15 @@ function Switch({
data-slot="switch" data-slot="switch"
data-size={size} data-size={size}
className={cn( className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6", "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=sm]:h-3.5 data-[size=default]:w-8 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className className,
)} )}
{...props} {...props}
> >
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb" data-slot="switch-thumb"
className={cn( className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0" "pointer-events-none block rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
)} )}
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
+33 -13
View File
@@ -1,25 +1,32 @@
"use client"; "use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs"; import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Tabs({ className, orientation = "horizontal", ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) { function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return ( return (
<TabsPrimitive.Root <TabsPrimitive.Root
data-slot="tabs" data-slot="tabs"
data-orientation={orientation} data-orientation={orientation}
orientation={orientation} orientation={orientation}
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)} className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props} {...props}
/> />
); );
} }
const tabsListVariants = cva( const tabsListVariants = cva(
"group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground data-[variant=line]:rounded-none group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{ {
variants: { variants: {
variant: { variant: {
@@ -30,14 +37,15 @@ const tabsListVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
); );
function TabsList({ function TabsList({
className, className,
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) { }: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
data-slot="tabs-list" data-slot="tabs-list"
@@ -48,24 +56,36 @@ function TabsList({
); );
} }
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) { function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 font-medium text-foreground/60 text-sm transition-all hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground", "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100", "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className className,
)} )}
{...props} {...props}
/> />
); );
} }
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) { function TabsContent({
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />; className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
} }
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+3 -3
View File
@@ -1,4 +1,4 @@
import * as React from "react"; import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -7,8 +7,8 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "field-sizing-content flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
+11 -9
View File
@@ -1,11 +1,10 @@
"use client"; "use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority"; import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle"; import { toggleVariants } from "@/components/ui/toggle";
import { cn } from "@/lib/utils";
const ToggleGroupContext = React.createContext< const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & { VariantProps<typeof toggleVariants> & {
@@ -37,11 +36,13 @@ function ToggleGroup({
style={{ "--gap": spacing } as React.CSSProperties} style={{ "--gap": spacing } as React.CSSProperties}
className={cn( className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs", "group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className className,
)} )}
{...props} {...props}
> >
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>{children}</ToggleGroupContext.Provider> <ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root> </ToggleGroupPrimitive.Root>
); );
} }
@@ -52,7 +53,8 @@ function ToggleGroupItem({
variant, variant,
size, size,
...props ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) { }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext); const context = React.useContext(ToggleGroupContext);
return ( return (
@@ -67,8 +69,8 @@ function ToggleGroupItem({
size: context.size || size, size: context.size || size,
}), }),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10", "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l", "data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:first:border-l data-[spacing=0]:first:rounded-l-md",
className className,
)} )}
{...props} {...props}
> >
+12 -6
View File
@@ -1,18 +1,19 @@
"use client"; "use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle"; import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const toggleVariants = cva( const toggleVariants = cva(
"hover:bg-muted hover:text-muted-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: "bg-transparent",
outline: "border-input hover:bg-accent hover:text-accent-foreground border bg-transparent shadow-xs", outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
}, },
size: { size: {
default: "h-9 min-w-9 px-2", default: "h-9 min-w-9 px-2",
@@ -24,7 +25,7 @@ const toggleVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
); );
function Toggle({ function Toggle({
@@ -32,9 +33,14 @@ function Toggle({
variant, variant,
size, size,
...props ...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) { }: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return ( return (
<TogglePrimitive.Root data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} /> <TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
); );
} }
+21 -8
View File
@@ -1,15 +1,26 @@
"use client"; "use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) { function TooltipProvider({
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />; delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
} }
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) { function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return ( return (
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
@@ -17,7 +28,9 @@ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root
); );
} }
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
@@ -33,13 +46,13 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
); );
+10 -1
View File
@@ -36,7 +36,16 @@ const Video = ({
const extension = file.split(".").pop(); const extension = file.split(".").pop();
if (extension === "vtt") { if (extension === "vtt") {
return <track key={file} kind="subtitles" src={file} srcLang="en" label="English" default />; return (
<track
key={file}
kind="subtitles"
src={file}
srcLang="en"
label="English"
default
/>
);
} else { } else {
return <source key={file} src={file} type={`video/${extension}`} />; return <source key={file} src={file} type={`video/${extension}`} />;
} }
+4 -2
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { env } from "@/lib/env";
import { CountUp } from "@/components/count-up"; import { CountUp } from "@/components/count-up";
import { env } from "@/lib/env";
import { incrementViews } from "@/lib/server/views"; import { incrementViews } from "@/lib/server/views";
const ViewCounter = ({ slug }: { slug: string }) => { const ViewCounter = ({ slug }: { slug: string }) => {
@@ -30,7 +30,9 @@ const ViewCounter = ({ slug }: { slug: string }) => {
} }
return ( return (
<span title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(views)} ${views === 1 ? "view" : "views"}`}> <span
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(views)} ${views === 1 ? "view" : "views"}`}
>
<CountUp start={0} end={views} delay={0} duration={1.5} /> <CountUp start={0} end={views} delay={0} duration={1.5} />
</span> </span>
); );
+2 -1
View File
@@ -3,9 +3,10 @@ import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
schema: "./lib/db/schema.ts", schema: "./lib/db/schema.ts",
out: "./lib/db/migrations", out: "./drizzle",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
// biome-ignore lint/style/noNonNullAssertion: runs outside Next.js; can't use env helper from @t3-oss/env-nextjs
url: process.env.DATABASE_URL!, url: process.env.DATABASE_URL!,
}, },
}); });
-81
View File
@@ -1,81 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import { FlatCompat } from "@eslint/eslintrc";
import js from "@eslint/js";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import * as eslintPluginMdx from "eslint-plugin-mdx";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import eslintCustomConfig from "@jakejarvis/eslint-config";
/** @type {import("@eslint/eslintrc").FlatCompat} */
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
recommendedConfig: js.configs.recommended,
});
const eslintConfig = defineConfig([
// Next.js core-web-vitals and TypeScript configs
...nextVitals,
...nextTs,
// Other plugins via compat
...compat.config({
plugins: ["react-compiler", "css-modules"],
extends: ["plugin:css-modules/recommended", "plugin:drizzle/recommended"],
}),
// Custom configs
...eslintCustomConfig,
eslintPluginPrettierRecommended,
// Custom rules
{
rules: {
camelcase: [
"error",
{
allow: ["^experimental_", "^unstable_"],
},
],
"prettier/prettier": [
"error",
{},
{
usePrettierrc: true,
},
],
},
},
{
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
rules: {
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "warn",
"react-compiler/react-compiler": "error",
},
},
// MDX support
{
...eslintPluginMdx.flat,
processor: eslintPluginMdx.createRemarkProcessor({
lintCodeBlocks: false,
}),
rules: {
...eslintPluginMdx.flat.rules,
"mdx/remark": "warn", // keep as warn (matches default)
"mdx/code-blocks": "off",
"react/jsx-no-undef": "off", // components are injected automatically from mdx-components.ts
"react/no-unescaped-entities": "off",
"@typescript-eslint/no-unused-vars": "off", // MDX files often import components that are used implicitly
},
},
// Ignores (override Next.js defaults)
globalIgnores([
".next/**",
"out/**",
"build/**",
".vercel/**",
"next-env.d.ts",
"node_modules/**",
"lib/db/migrations/**",
]),
]);
export default eslintConfig;
+1 -1
View File
@@ -1,5 +1,5 @@
import { env } from "@/lib/env";
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { env } from "@/lib/env";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: env.NEXT_PUBLIC_BASE_URL, baseURL: env.NEXT_PUBLIC_BASE_URL,
+3 -3
View File
@@ -1,9 +1,9 @@
import { env } from "@/lib/env"; import { type BetterAuthOptions, betterAuth } from "better-auth";
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import * as schema from "@/lib/db/schema"; import * as schema from "@/lib/db/schema";
import { env } from "@/lib/env";
export const auth = betterAuth({ export const auth = betterAuth({
baseURL: env.NEXT_PUBLIC_BASE_URL, baseURL: env.NEXT_PUBLIC_BASE_URL,
+9 -7
View File
@@ -1,10 +1,9 @@
import { env } from "@/lib/env";
import { Feed, type Item as FeedItem } from "feed"; import { Feed, type Item as FeedItem } from "feed";
import { getFrontMatter, getContent } from "@/lib/posts";
import siteConfig from "@/lib/config/site";
import authorConfig from "@/lib/config/author";
import ogImage from "@/app/opengraph-image.jpg"; import ogImage from "@/app/opengraph-image.jpg";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
import { env } from "@/lib/env";
import { getContent, getFrontMatter } from "@/lib/posts";
/** /**
* Returns a `Feed` object, which can then be processed with `feed.rss2()`, `feed.atom1()`, or `feed.json1()`. * Returns a `Feed` object, which can then be processed with `feed.rss2()`, `feed.atom1()`, or `feed.json1()`.
@@ -49,11 +48,14 @@ export const buildFeed = async (): Promise<Feed> => {
${await getContent(post.slug)} ${await getContent(post.slug)}
<p><a href="${post.permalink}"><strong>Continue reading...</strong></a></p> <p><a href="${post.permalink}"><strong>Continue reading...</strong></a></p>
`.trim(), `.trim(),
})) })),
); );
// sort posts reverse chronologically in case the promises resolved out of order // sort posts reverse chronologically in case the promises resolved out of order
posts.sort((post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime()); posts.sort(
(post1, post2) =>
new Date(post2.date).getTime() - new Date(post1.date).getTime(),
);
// officially add each post to the feed // officially add each post to the feed
posts.forEach((post) => { posts.forEach((post) => {
+2 -2
View File
@@ -1,8 +1,8 @@
import { env } from "@/lib/env";
import * as schema from "@/lib/db/schema";
import { attachDatabasePool } from "@vercel/functions"; import { attachDatabasePool } from "@vercel/functions";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg"; import { Pool } from "pg";
import * as schema from "@/lib/db/schema";
import { env } from "@/lib/env";
// Create explicit pool instance for better connection management // Create explicit pool instance for better connection management
const pool = new Pool({ const pool = new Pool({
+12 -2
View File
@@ -1,4 +1,12 @@
import { pgTable, text, timestamp, boolean, integer, uuid, type AnyPgColumn } from "drizzle-orm/pg-core"; import {
type AnyPgColumn,
boolean,
integer,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -61,7 +69,9 @@ export const comment = pgTable("comment", {
pageSlug: text("page_slug") pageSlug: text("page_slug")
.notNull() .notNull()
.references(() => page.slug), .references(() => page.slug),
parentId: uuid("parent_id").references((): AnyPgColumn => comment.id, { onDelete: "cascade" }), parentId: uuid("parent_id").references((): AnyPgColumn => comment.id, {
onDelete: "cascade",
}),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
+2 -2
View File
@@ -89,7 +89,7 @@ export const env = createEnv({
? `${process.env.DEPLOY_URL}` ? `${process.env.DEPLOY_URL}`
: undefined : undefined
: undefined) || : undefined) ||
`http://localhost:${process.env.PORT || 3000}`)() `http://localhost:${process.env.PORT || 3000}`)(),
), ),
/** /**
@@ -102,7 +102,7 @@ export const env = createEnv({
(process.env.VERCEL && process.env.VERCEL_ENV === "production") || (process.env.VERCEL && process.env.VERCEL_ENV === "production") ||
(process.env.NETLIFY && process.env.CONTEXT === "production") (process.env.NETLIFY && process.env.CONTEXT === "production")
? "production" ? "production"
: "development")() : "development")(),
), ),
/** Required. GitHub repository for the site in the format of `{username}/{repo}`. */ /** Required. GitHub repository for the site in the format of `{username}/{repo}`. */

Some files were not shown because too many files have changed in this diff Show More