├── .github
└── ISSUE_TEMPLATE
│ └── request-provider.md
├── .gitignore
├── .gitpod.yml
├── .vscode
└── settings.json
├── DB.sqlite
├── LICENSE
├── README.md
├── biome.jsonc
├── bun.lockb
├── components.json
├── media
└── home.png
├── next.config.mjs
├── package.json
├── postcss.config.mjs
├── public
├── deploys-top.png
├── deploys-top.svg
├── next.svg
└── vercel.svg
├── sanity.cli.ts
├── sanity.config.ts
├── src
├── actions
│ └── get-random-most-voted-provider.ts
├── app
│ ├── api
│ │ └── og-image
│ │ │ └── route.tsx
│ ├── auth
│ │ ├── actions
│ │ │ └── index.ts
│ │ └── github
│ │ │ ├── callback
│ │ │ └── route.ts
│ │ │ └── route.ts
│ ├── globals.css
│ ├── layout.tsx
│ ├── not-found.tsx
│ ├── page.tsx
│ ├── providers
│ │ ├── [provider]
│ │ │ ├── actions
│ │ │ │ └── index.ts
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── studio
│ │ └── [[...index]]
│ │ └── page.tsx
├── auth.ts
├── components
│ ├── header.tsx
│ ├── icons
│ │ └── github.tsx
│ ├── menu.tsx
│ ├── new-header.tsx
│ ├── progress-bar-provider.tsx
│ ├── provider-header.tsx
│ ├── provider-service-dialog.tsx
│ ├── providers-carousel.tsx
│ ├── providers
│ │ ├── filter-popover.tsx
│ │ ├── list.tsx
│ │ ├── provider-bottom-bar.tsx
│ │ ├── provider-card.tsx
│ │ └── toolbar.tsx
│ ├── search-menu.tsx
│ ├── theme-provider.tsx
│ ├── theme-toggle.tsx
│ └── ui
│ │ ├── accordion.tsx
│ │ ├── animated-group.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── carousel.tsx
│ │ ├── checkbox.tsx
│ │ ├── command.tsx
│ │ ├── credenza.tsx
│ │ ├── custom-navigation-menu.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── page-header.tsx
│ │ ├── popover.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sonner.tsx
│ │ ├── tabs.tsx
│ │ └── tooltip.tsx
├── config.ts
├── db.ts
├── fonts
│ └── InterVariable.woff2
├── lib
│ ├── children.tsx
│ ├── groq-queries.ts
│ ├── hooks
│ │ ├── use-event-listener.ts
│ │ ├── use-hover.ts
│ │ ├── use-intersection-observer.ts
│ │ └── use-media-query.ts
│ ├── lucide-icon.tsx
│ ├── search-params.ts
│ └── utils.ts
├── sanity
│ ├── env.ts
│ ├── lib
│ │ ├── client.ts
│ │ └── image.ts
│ ├── schema.ts
│ └── schemas
│ │ ├── category.ts
│ │ ├── plan.ts
│ │ └── provider.ts
├── styles
│ └── animations.css
└── types
│ ├── category.ts
│ └── provider.ts
└── tsconfig.json
/.github/ISSUE_TEMPLATE/request-provider.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Request Provider
3 | about: Request a provider to include.
4 | title: "[➕] Provider Request"
5 | labels: provider request
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## Provider Information:
11 |
12 | - **Name**:
13 | - **Website**:
14 | - **Pricing page**:
15 |
16 | > **All fields are required**
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 |
39 | /dist/
40 | .sanity
41 | .env
42 | .env copy
43 | .idea
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 | image: gitpod/workspace-full:latest
2 |
3 | tasks:
4 | - name: Start Application
5 | init: pnpm install
6 | command: pnpm run dev
7 | ports:
8 | - name: Next.js Application
9 | description: Port 3000 for the next.js app
10 | port: 3000
11 | onOpen: notify
12 | visibility: public
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "biomejs.biome",
3 | "[typescript]": {
4 | "editor.defaultFormatter": "biomejs.biome"
5 | },
6 | "[typescriptreact]": {
7 | "editor.defaultFormatter": "biomejs.biome"
8 | },
9 | "[css]": {
10 | "editor.defaultFormatter": "biomejs.biome"
11 | },
12 | "[sql]": {
13 | "editor.defaultFormatter": "biomejs.biome"
14 | },
15 | "editor.formatOnSave": true,
16 | "editor.codeActionsOnSave": {
17 | "quickfix.biome": "explicit",
18 | "source.organizeImports.biome": "explicit"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/DB.sqlite:
--------------------------------------------------------------------------------
1 | CREATE TABLE
2 | IF NOT EXISTS user (
3 | id TEXT NOT NULL PRIMARY KEY,
4 | username TEXT,
5 | avatar_url TEXT,
6 | github_id INTEGER
7 | );
8 |
9 | CREATE TABLE
10 | IF NOT EXISTS session (
11 | id TEXT NOT NULL PRIMARY KEY,
12 | expires_at INTEGER NOT NULL,
13 | user_id TEXT NOT NULL,
14 | FOREIGN KEY (user_id) REFERENCES user (id)
15 | );
16 |
17 | CREATE TABLE
18 | IF NOT EXISTS vote (
19 | vote_id INTEGER PRIMARY KEY AUTOINCREMENT,
20 | provider_id TEXT NOT NULL,
21 | user_id TEXT NOT NULL,
22 | vote_type TEXT NOT NULL,
23 | FOREIGN KEY (user_id) REFERENCES user (id)
24 | );
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Deploys Top
2 |
3 | 
4 |
5 | 🌐 Link to website
6 |
7 |
8 | Compare your favorite providers filtering with your favorite technologies, such as Cloudflare or Amazon Web Services!
9 |
10 | ## Features
11 |
12 | - **Technology Filtering**: Filter providers based on your preferred technologies.
13 | - **Direct link to pricing**: You can easily access the pricing of the provider.
14 | - **Specs of each tier**: Get the features each provider offers across all tiers!
15 |
16 | ## Requesting provider
17 |
18 | Open an issue on our GitHub repository using the [provider request template](https://github.com/NoHaxito/deploys-top/issues/new?assignees=&labels=provider+request&projects=&template=request-provider.md&title=%5B%E2%9E%95%5D+Provider+Request).
19 |
20 | ## Contribution
21 |
22 | Feel free to contribute! This app is made with Next.js.
23 |
--------------------------------------------------------------------------------
/biome.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.7.2/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "files": {
7 | "ignore": ["node_modules", ".next"]
8 | },
9 | "linter": {
10 | "enabled": true,
11 | "rules": {
12 | "recommended": true,
13 | "nursery": {
14 | "useSortedClasses": "warn"
15 | },
16 | "a11y": {
17 | "noSvgWithoutTitle": "off"
18 | },
19 | "correctness": {
20 | "noUnusedImports": "warn",
21 | "useExhaustiveDependencies": "off"
22 | },
23 | "complexity": {
24 | "noForEach": "off"
25 | }
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NoHaxito/deploys-top/6092c6bde67aa1100cf3b07b338b1f50d2f56835/bun.lockb
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/media/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NoHaxito/deploys-top/6092c6bde67aa1100cf3b07b338b1f50d2f56835/media/home.png
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: false,
4 | transpilePackages: ["lucide-react"],
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "deploystop",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "next:lint": "next lint",
10 | "lint": "biome lint --apply .",
11 | "format": "biome format --write .",
12 | "check": "biome check --apply ."
13 | },
14 | "dependencies": {
15 | "@libsql/client": "^0.6.2",
16 | "@libsql/kysely-libsql": "^0.3.0",
17 | "@lucia-auth/adapter-sqlite": "^3.0.1",
18 | "@radix-ui/react-accordion": "^1.2.4",
19 | "@radix-ui/react-checkbox": "^1.1.5",
20 | "@radix-ui/react-dialog": "^1.1.7",
21 | "@radix-ui/react-dropdown-menu": "^2.1.7",
22 | "@radix-ui/react-label": "^2.1.3",
23 | "@radix-ui/react-navigation-menu": "^1.2.6",
24 | "@radix-ui/react-popover": "^1.1.7",
25 | "@radix-ui/react-scroll-area": "^1.2.4",
26 | "@radix-ui/react-select": "^2.1.7",
27 | "@radix-ui/react-separator": "^1.1.3",
28 | "@radix-ui/react-slot": "^1.2.0",
29 | "@radix-ui/react-tabs": "^1.1.4",
30 | "@radix-ui/react-tooltip": "^1.2.0",
31 | "@sanity/image-url": "1",
32 | "@sanity/vision": "3",
33 | "arctic": "^1.9.1",
34 | "class-variance-authority": "^0.7.0",
35 | "clsx": "^2.1.1",
36 | "cmdk": "^1.1.1",
37 | "embla-carousel-autoplay": "^8.0.2",
38 | "embla-carousel-react": "^8.0.2",
39 | "eslint-config-next": "15.3.0",
40 | "framer-motion": "^11.2.10",
41 | "kysely": "^0.27.3",
42 | "lodash": "^4.17.21",
43 | "lucia": "^3.2.0",
44 | "lucide-react": "^0.488.0",
45 | "motion": "^12.7.2",
46 | "next": "15.3.0",
47 | "next-nprogress-bar": "^2.3.11",
48 | "next-sanity": "7",
49 | "next-themes": "^0.3.0",
50 | "nuqs": "^2.4.2",
51 | "react": "19.1.0",
52 | "react-dom": "19.1.0",
53 | "recharts": "^2.15.2",
54 | "sanity": "3",
55 | "sonner": "^1.5.0",
56 | "styled-components": "6",
57 | "tailwind-merge": "^3.2.0",
58 | "tailwindcss-animate": "^1.0.7",
59 | "tw-animate-css": "^1.2.5",
60 | "vaul": "^1.1.2"
61 | },
62 | "devDependencies": {
63 | "@biomejs/biome": "^1.9.4",
64 | "@tailwindcss/postcss": "^4.1.4",
65 | "@types/node": "^20",
66 | "@types/react": "19.1.1",
67 | "@types/react-dom": "19.1.2",
68 | "postcss": "^8",
69 | "tailwindcss": "^4.1.4",
70 | "typescript": "^5"
71 | },
72 | "overrides": {
73 | "@types/react": "19.1.1",
74 | "@types/react-dom": "19.1.2"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | '@tailwindcss/postcss': {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/deploys-top.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NoHaxito/deploys-top/6092c6bde67aa1100cf3b07b338b1f50d2f56835/public/deploys-top.png
--------------------------------------------------------------------------------
/public/deploys-top.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
18 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/sanity.cli.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This configuration file lets you run `$ sanity [command]` in this folder
3 | * Go to https://www.sanity.io/docs/cli to learn more.
4 | **/
5 | import { defineCliConfig } from 'sanity/cli'
6 |
7 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
8 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET
9 |
10 | export default defineCliConfig({ api: { projectId, dataset } })
11 |
--------------------------------------------------------------------------------
/sanity.config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This configuration is used to for the Sanity Studio that’s mounted on the `/app/studio/[[...index]]/page.tsx` route
3 | */
4 | import { visionTool } from "@sanity/vision";
5 | import { defineConfig } from "sanity";
6 | import { structureTool } from "sanity/structure";
7 | // Go to https://www.sanity.io/docs/api-versioning to learn how API versioning works
8 | import { apiVersion, dataset, projectId } from "./src/sanity/env";
9 | import { schema } from "./src/sanity/schema";
10 |
11 | export default defineConfig({
12 | basePath: "/studio",
13 | name: "Studio",
14 | projectId,
15 | dataset,
16 | // Add and edit the content schema in the './sanity/schema' folder
17 | schema,
18 | plugins: [
19 | structureTool(),
20 | // Vision is a tool that lets you query your content with GROQ in the studio
21 | // https://www.sanity.io/docs/the-vision-plugin
22 | visionTool({ defaultApiVersion: apiVersion }),
23 | ],
24 | });
25 |
--------------------------------------------------------------------------------
/src/actions/get-random-most-voted-provider.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/db";
4 | import { queries } from "@/lib/groq-queries";
5 | import { client } from "@/sanity/lib/client";
6 | import type { Provider } from "@/types/provider";
7 |
8 | export async function getRandomMostVotedProvider() {
9 | const providers = await client.fetch(queries.allProviders);
10 |
11 | // Get vote counts using SQL aggregation
12 | const voteCounts = await db
13 | .selectFrom("vote")
14 | .select(["provider_id"])
15 | .select((eb) => [
16 | eb.fn
17 | .count("vote_type")
18 | .filterWhere("vote_type", "=", "upvote")
19 | .as("upvotes"),
20 | ])
21 | .groupBy("provider_id")
22 | // @ts-ignore
23 | .having("upvotes", ">", 0)
24 | .execute();
25 |
26 | if (voteCounts.length === 0) {
27 | // If no votes, return a random provider
28 | const randomIndex = Math.floor(Math.random() * providers.length);
29 | return JSON.stringify(providers[randomIndex]);
30 | }
31 |
32 | const randomVote = voteCounts[Math.floor(Math.random() * voteCounts.length)];
33 | const randomProvider = providers.find(
34 | (provider) => provider.id === randomVote.provider_id,
35 | );
36 |
37 | // Fallback to first provider if somehow the voted provider is not found
38 | return JSON.stringify(randomProvider || providers[0]);
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/api/og-image/route.tsx:
--------------------------------------------------------------------------------
1 | import { queries } from "@/lib/groq-queries";
2 | import { client } from "@/sanity/lib/client";
3 | import type { Provider } from "@/types/provider";
4 | import { ImageResponse } from "next/og";
5 | import type { NextRequest } from "next/server";
6 |
7 | export const runtime = "edge";
8 | export const revalidate = 60;
9 |
10 | export async function GET(req: NextRequest) {
11 | const { searchParams } = req.nextUrl;
12 | const provider = searchParams.get("provider");
13 | const providerLogo = (
14 | await client.fetch(queries.getProvider, {
15 | id: provider,
16 | })
17 | )?.icon;
18 |
19 | if (!provider || !providerLogo) {
20 | return new ImageResponse(
21 |
35 |
36 |
37 | {/* biome-ignore lint/a11y/useAltText:
*/}
38 |
46 | Deploys.top
47 |
48 |
49 | Compare your favorite providers
50 |
51 |
52 |
,
53 | {
54 | width: 1200,
55 | height: 630,
56 | },
57 | );
58 | }
59 |
60 | return new ImageResponse(
61 |
74 |
75 |
76 | {/* biome-ignore lint/a11y/useAltText:
*/}
77 |
85 | ›
86 | {/* biome-ignore lint/a11y/useAltText: */}
87 |
95 |
96 |
97 | Compare your favorite providers
98 |
99 |
100 |
,
101 | {
102 | width: 1200,
103 | height: 630,
104 | },
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/src/app/auth/actions/index.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import { getSession, lucia } from "@/auth";
3 | import { cookies } from "next/headers";
4 | import { redirect } from "next/navigation";
5 |
6 | export const logout = async (redirectTo = "/") => {
7 | const { session } = await getSession();
8 | if (!session) {
9 | return {
10 | error: "Unauthorized",
11 | };
12 | }
13 |
14 | await lucia.invalidateSession(session.id);
15 |
16 | const sessionCookie = lucia.createBlankSessionCookie();
17 | (await cookies()).set(
18 | sessionCookie.name,
19 | sessionCookie.value,
20 | sessionCookie.attributes
21 | );
22 | return redirect(redirectTo);
23 | };
24 |
--------------------------------------------------------------------------------
/src/app/auth/github/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { github, lucia } from "@/auth";
2 | import { cookies } from "next/headers";
3 | import { OAuth2RequestError } from "arctic";
4 | import { generateId } from "lucia";
5 | import { db } from "@/db";
6 | import type { NextRequest } from "next/server";
7 |
8 | export async function GET(request: NextRequest): Promise {
9 | const url = new URL(request.url);
10 | const code = url.searchParams.get("code");
11 | const state = url.searchParams.get("state");
12 | const storedNext = (await cookies()).get("next")?.value ?? "/";
13 | const storedState = (await cookies()).get("github_oauth_state")?.value ?? null;
14 | if (!code || !state || !storedState || state !== storedState) {
15 | return new Response(null, {
16 | status: 400,
17 | });
18 | }
19 |
20 | try {
21 | const tokens = await github.validateAuthorizationCode(code);
22 | const githubUser: GitHubUser = await fetch("https://api.github.com/user", {
23 | headers: {
24 | Authorization: `Bearer ${tokens.accessToken}`,
25 | },
26 | }).then((res) => res.json());
27 |
28 | const existingUser = await db
29 | .selectFrom("user")
30 | .where("github_id", "=", githubUser.id)
31 | .selectAll()
32 | .executeTakeFirst();
33 |
34 | if (existingUser) {
35 | const session = await lucia.createSession(existingUser.id, {});
36 | const sessionCookie = lucia.createSessionCookie(session.id);
37 | (await cookies()).set(
38 | sessionCookie.name,
39 | sessionCookie.value,
40 | sessionCookie.attributes
41 | );
42 | (await cookies()).delete("next");
43 | return new Response(null, {
44 | status: 302,
45 | headers: {
46 | Location: storedNext,
47 | },
48 | });
49 | }
50 |
51 | const userId = generateId(15);
52 | await db
53 | .insertInto("user")
54 | .values({
55 | id: userId,
56 | github_id: githubUser.id,
57 | username: githubUser.login,
58 | avatar_url: githubUser.avatar_url,
59 | })
60 | .execute();
61 |
62 | const session = await lucia.createSession(userId, {});
63 | const sessionCookie = lucia.createSessionCookie(session.id);
64 | (await cookies()).set(
65 | sessionCookie.name,
66 | sessionCookie.value,
67 | sessionCookie.attributes
68 | );
69 | return new Response(null, {
70 | status: 302,
71 | headers: {
72 | Location: "/",
73 | },
74 | });
75 |
76 | // biome-ignore lint/suspicious/noExplicitAny:
77 | } catch (e: any) {
78 | console.log(e);
79 | if (e instanceof OAuth2RequestError) {
80 | // invalid code
81 | return new Response(null, {
82 | status: 400,
83 | });
84 | }
85 | return new Response(null, {
86 | status: 500,
87 | });
88 | }
89 |
90 | interface GitHubUser {
91 | id: number;
92 | login: string;
93 | email: string | null;
94 | avatar_url: string;
95 | }
96 | interface GithubEmails {
97 | email: string;
98 | primary: boolean;
99 | verified: boolean;
100 | visibility: string;
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/app/auth/github/route.ts:
--------------------------------------------------------------------------------
1 | import { github } from "@/auth";
2 | import { generateState } from "arctic";
3 | import { cookies } from "next/headers";
4 |
5 | export async function GET(request: Request): Promise {
6 | const state = generateState();
7 | const next = new URL(request.url).searchParams.get("next") ?? "/";
8 | (await cookies()).set("next", next, {
9 | secure: process.env.NODE_ENV === "production",
10 | });
11 | const url = await github.createAuthorizationURL(state, {
12 | scopes: ["user:email"],
13 | });
14 |
15 | (await cookies()).set("github_oauth_state", state, {
16 | path: "/",
17 | secure: process.env.NODE_ENV === "production",
18 | httpOnly: true,
19 | maxAge: 60 * 10,
20 | sameSite: "lax",
21 | });
22 |
23 | return Response.redirect(url);
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @custom-variant dark (&:is(.dark *));
3 |
4 | @import "tw-animate-css";
5 | @import '../styles/animations.css';
6 |
7 | @theme {
8 | --breakpoint-xs: 475px;
9 |
10 | --font-sans:
11 | var(--font-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
12 | 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
13 |
14 | --color-border: hsl(var(--border));
15 | --color-input: hsl(var(--input));
16 | --color-ring: hsl(var(--ring));
17 | --color-background: hsl(var(--background));
18 | --color-foreground: hsl(var(--foreground));
19 |
20 | --color-primary: hsl(var(--primary));
21 | --color-primary-foreground: hsl(var(--primary-foreground));
22 |
23 | --color-secondary: hsl(var(--secondary));
24 | --color-secondary-foreground: hsl(var(--secondary-foreground));
25 |
26 | --color-destructive: hsl(var(--destructive));
27 | --color-destructive-foreground: hsl(var(--destructive-foreground));
28 |
29 | --color-muted: hsl(var(--muted));
30 | --color-muted-foreground: hsl(var(--muted-foreground));
31 |
32 | --color-accent: hsl(var(--accent));
33 | --color-accent-foreground: hsl(var(--accent-foreground));
34 |
35 | --color-popover: hsl(var(--popover));
36 | --color-popover-foreground: hsl(var(--popover-foreground));
37 |
38 | --color-card: hsl(var(--card));
39 | --color-card-foreground: hsl(var(--card-foreground));
40 |
41 | --radius-lg: var(--radius);
42 | --radius-md: calc(var(--radius) - 4px);
43 | --radius-sm: calc(var(--radius) - 8px);
44 |
45 | --animate-accordion-down: accordion-down 0.2s ease-out;
46 | --animate-accordion-up: accordion-up 0.2s ease-out;
47 |
48 | @keyframes accordion-down {
49 | from {
50 | height: 0;
51 | }
52 | to {
53 | height: var(--radix-accordion-content-height);
54 | }
55 | }
56 | @keyframes accordion-up {
57 | from {
58 | height: var(--radix-accordion-content-height);
59 | }
60 | to {
61 | height: 0;
62 | }
63 | }
64 | }
65 |
66 | @utility container {
67 | margin-inline: auto;
68 | padding-inline: 1.5rem;
69 | @media (width >= --theme(--breakpoint-xs)) {
70 | max-width: none;
71 | }
72 | @media (width >= 100%) {
73 | max-width: 100%;
74 | padding-inline: 2.5rem;
75 | }
76 | @media (width >= 80rem) {
77 | max-width: 80rem;
78 | padding-inline: 6rem;
79 | }
80 | }
81 |
82 | /*
83 | The default border color has changed to `currentcolor` in Tailwind CSS v4,
84 | so we've added these compatibility styles to make sure everything still
85 | looks the same as it did with Tailwind CSS v3.
86 |
87 | If we ever want to remove these styles, we need to add an explicit border
88 | color utility to any element that depends on these defaults.
89 | */
90 | @layer base {
91 | *,
92 | ::after,
93 | ::before,
94 | ::backdrop,
95 | ::file-selector-button {
96 | border-color: var(--color-gray-200, currentcolor);
97 | }
98 | }
99 |
100 | @layer base {
101 | :root {
102 | --background: 0 0% 100%;
103 | --foreground: 0 0% 3.9%;
104 | --card: 0 0% 100%;
105 | --card-foreground: 0 0% 3.9%;
106 | --popover: 0 0% 100%;
107 | --popover-foreground: 0 0% 3.9%;
108 | --primary: 0 0% 9%;
109 | --primary-foreground: 0 0% 98%;
110 | --secondary: 0 0% 96.1%;
111 | --secondary-foreground: 0 0% 9%;
112 | --muted: 0 0% 96.1%;
113 | --muted-foreground: 0 0% 45.1%;
114 | --accent: 0 0% 96.1%;
115 | --accent-foreground: 0 0% 9%;
116 | --destructive: 0 84.2% 60.2%;
117 | --destructive-foreground: 0 0% 98%;
118 | --border: 0 0% 89.8%;
119 | --input: 0 0% 89.8%;
120 | --ring: 0 0% 3.9%;
121 | --radius: 0.75rem;
122 | }
123 |
124 | .dark {
125 | --background: 0 0% 3.9%;
126 | --foreground: 0 0% 98%;
127 | --card: 0 0% 3.9%;
128 | --card-foreground: 0 0% 98%;
129 | --popover: 0 0% 3.9%;
130 | --popover-foreground: 0 0% 98%;
131 | --primary: 0 0% 98%;
132 | --primary-foreground: 0 0% 9%;
133 | --secondary: 0 0% 14.9%;
134 | --secondary-foreground: 0 0% 98%;
135 | --muted: 0 0% 14.9%;
136 | --muted-foreground: 0 0% 63.9%;
137 | --accent: 0 0% 14.9%;
138 | --accent-foreground: 0 0% 98%;
139 | --destructive: 0 62.8% 30.6%;
140 | --destructive-foreground: 0 0% 98%;
141 | --border: 0 0% 14.9%;
142 | --input: 0 0% 14.9%;
143 | --ring: 0 0% 83.1%;
144 | }
145 | }
146 |
147 | @layer base {
148 | * {
149 | @apply border-border;
150 | }
151 | body {
152 | @apply bg-background text-foreground;
153 | }
154 | .aws-logo {
155 | -webkit-filter: drop-shadow(1px 1px 0 white) drop-shadow(-1px 1px 0 white)
156 | drop-shadow(1px -1px 0 white) drop-shadow(-1px -1px 0 white);
157 | filter: drop-shadow(1px 1px 0 white) drop-shadow(-1px 1px 0 white)
158 | drop-shadow(1px -1px 0 white) drop-shadow(-1px -1px 0 white);
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ProgressBar } from "@/components/progress-bar-provider";
2 | import { ThemeProvider } from "@/components/theme-provider";
3 | import { cn } from "@/lib/utils";
4 | import type { Metadata } from "next";
5 | import localFont from "next/font/local";
6 | // import "@fontsource-variable/inter";
7 | import type React from "react";
8 | import "./globals.css";
9 | import { getSession } from "@/auth";
10 | import { Toaster } from "@/components/ui/sonner";
11 |
12 | import { Navbar } from "@/components/new-header";
13 | import { NuqsAdapter } from "nuqs/adapters/next/app";
14 |
15 | const interVariable = localFont({
16 | variable: "--font-sans",
17 | src: "../fonts/InterVariable.woff2",
18 | weight: "100 900",
19 | display: "swap",
20 | preload: true,
21 | });
22 |
23 | const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || "deploys-top.vercel.app";
24 |
25 | export const metadata: Metadata = {
26 | metadataBase: new URL(BASE_URL),
27 | title: {
28 | default: "Deploys.top",
29 | template: "%s - Deploys.top",
30 | },
31 | description: "Search and compare free and paid providers.",
32 | icons: {
33 | icon: "/deploys-top.png",
34 | },
35 | openGraph: {
36 | type: "website",
37 | locale: "en_US",
38 | url: BASE_URL,
39 | title: "Deploys.top",
40 | description: "Search and compare free and paid providers.",
41 | siteName: "Deploys.top",
42 | images: [
43 | {
44 | url: `${BASE_URL}/api/og-image`,
45 | width: 1200,
46 | height: 630,
47 | alt: "Deploys.top",
48 | },
49 | ],
50 | },
51 | twitter: {
52 | card: "summary_large_image",
53 | title: "Deploys.top",
54 | description: "Search and compare free and paid providers.",
55 | images: [`${BASE_URL}/api/og-image`],
56 | },
57 | robots: {
58 | index: true,
59 | follow: true,
60 | googleBot: {
61 | index: true,
62 | follow: true,
63 | "max-video-preview": -1,
64 | "max-image-preview": "large",
65 | "max-snippet": -1,
66 | },
67 | },
68 | };
69 |
70 | export default async function RootLayout({
71 | children,
72 | }: Readonly<{ children: React.ReactElement }>) {
73 | const { session, user } = await getSession();
74 | return (
75 |
81 |
87 |
93 |
94 |
95 |
96 | {/*
*/}
97 |
98 |
{children}
99 |
100 |
101 |
102 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | export default function NotFound() {
2 | return (
3 |
4 |
404 | Page Not Found
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { GithubIcon } from "@/components/icons/github";
2 | import { ProvidersCarousel } from "@/components/providers-carousel";
3 | import { Button } from "@/components/ui/button";
4 | import { queries } from "@/lib/groq-queries";
5 | import { client } from "@/sanity/lib/client";
6 | import type { Provider } from "@/types/provider";
7 | import {
8 | ArrowUpRight,
9 | ChevronRight,
10 | LucideLayoutPanelLeft,
11 | } from "lucide-react";
12 | import Link from "next/link";
13 |
14 | export const revalidate = 5;
15 |
16 | export default async function Home() {
17 | const providers = await client.fetch(queries.allProviders);
18 | return (
19 |
20 |
21 | {/*
22 |
23 | NEW › Compare providers
24 |
25 |
29 |
30 |
31 | */}
32 |
33 |
34 | Search & compare free and paid providers
35 |
36 |
37 | Find the best option for your needs quickly and easily!
38 |
We have +{providers.length} providers added on
39 | the list.
40 |
41 |
42 |
43 |
44 |
52 |
53 |
59 | {" "}
68 |
69 |
70 |
86 |
87 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/src/app/providers/[provider]/actions/index.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/db";
4 |
5 | export async function vote(
6 | provider_id: string,
7 | user_id: string,
8 | vote_type: "upvote" | "downvote",
9 | currentVote: "upvote" | "downvote" | undefined
10 | ) {
11 | try {
12 | if (currentVote !== undefined) {
13 | if (currentVote === vote_type) {
14 | await db
15 | .deleteFrom("vote")
16 | .where("provider_id", "=", provider_id)
17 | .where("user_id", "=", user_id)
18 | .execute();
19 | return JSON.stringify(true);
20 | }
21 | await db
22 | .updateTable("vote")
23 | .set({
24 | vote_type,
25 | })
26 | .where("provider_id", "=", provider_id)
27 | .where("user_id", "=", user_id)
28 | .execute();
29 | return JSON.stringify(true);
30 | }
31 | await db
32 | .insertInto("vote")
33 | .values({
34 | provider_id,
35 | user_id,
36 | vote_type,
37 | })
38 |
39 | .execute();
40 |
41 | return JSON.stringify(true);
42 | } catch (error) {
43 | return JSON.stringify(error);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/providers/[provider]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getSession } from "@/auth";
2 | import { ProviderHeader } from "@/components/provider-header";
3 | import { ProviderServiceDialog } from "@/components/provider-service-dialog";
4 | import { Badge } from "@/components/ui/badge";
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Card,
8 | CardDescription,
9 | CardFooter,
10 | CardHeader,
11 | CardTitle,
12 | } from "@/components/ui/card";
13 | import {
14 | Popover,
15 | PopoverContent,
16 | PopoverTrigger,
17 | } from "@/components/ui/popover";
18 | import { db } from "@/db";
19 | import { queries } from "@/lib/groq-queries";
20 | import { cn } from "@/lib/utils";
21 | import { client } from "@/sanity/lib/client";
22 | import type { Provider } from "@/types/provider";
23 | import { LucideChevronLeft, LucideInfo } from "lucide-react";
24 | import type { Metadata } from "next";
25 | import Link from "next/link";
26 | import { notFound } from "next/navigation";
27 |
28 | export const revalidate = 3600; // Revalidate every hour
29 |
30 | export async function generateStaticParams() {
31 | // Fetch all providers
32 | const providers = await client.fetch(queries.allProviders);
33 |
34 | return providers.map((provider) => ({
35 | provider: provider.id,
36 | }));
37 | }
38 |
39 | export async function generateMetadata(props: {
40 | params: Promise<{ provider: string }>;
41 | }): Promise {
42 | const { provider } = await props.params;
43 | const providerData = await client.fetch(queries.getProvider, {
44 | id: provider,
45 | });
46 |
47 | if (!providerData) {
48 | return {
49 | title: "Provider Not Found",
50 | description: "The requested provider could not be found.",
51 | };
52 | }
53 |
54 | const baseUrl =
55 | process.env.NEXT_PUBLIC_APP_URL || "https://deploys-top.vercel.app/";
56 | const ogImageUrl = `${baseUrl}/api/og-image?provider=${providerData.name
57 | .toLowerCase()
58 | .replaceAll(" ", "-")
59 | .replaceAll(".", "-")}`;
60 |
61 | return {
62 | title: providerData.name,
63 | description: "Search and compare free and paid providers.",
64 | openGraph: {
65 | type: "website",
66 | locale: "en_US",
67 | url: baseUrl,
68 | title: `${providerData.name} - Deploys.top`,
69 | description: "Search and compare free and paid providers.",
70 | siteName: "Deploys.top",
71 | images: [
72 | {
73 | url: ogImageUrl,
74 | width: 1200,
75 | height: 630,
76 | alt: "Deploys.top",
77 | },
78 | ],
79 | },
80 | twitter: {
81 | card: "summary_large_image",
82 | title: `${providerData.name} - Deploys.top`,
83 | description: "Search and compare free and paid providers.",
84 | images: [ogImageUrl],
85 | },
86 | };
87 | }
88 |
89 | // Move votes fetching to a separate server action or API route for dynamic data
90 | async function getAllVotes(provider_id: string) {
91 | try {
92 | const votes = await db
93 | .selectFrom("vote")
94 | .selectAll()
95 | .where("provider_id", "=", provider_id)
96 | .execute();
97 | return { votes };
98 | } catch (error) {
99 | console.error("Error fetching votes:", error);
100 | return { votes: [] };
101 | }
102 | }
103 |
104 | export default async function ProviderPage({
105 | params,
106 | }: {
107 | params: Promise<{ provider: string }>;
108 | }) {
109 | const awaitedParams = await params;
110 | // Fetch provider data
111 | const provider = await client.fetch(queries.getProvider, {
112 | id: awaitedParams.provider,
113 | });
114 |
115 | if (!provider) return notFound();
116 |
117 | // Get session and votes data
118 | const [sessionData, votesData] = await Promise.all([
119 | getSession(),
120 | getAllVotes(provider.id),
121 | ]);
122 |
123 | const { user } = sessionData;
124 | const { votes } = votesData;
125 |
126 | return (
127 |
128 |
138 |
139 |
143 | Categories
144 |
145 | {provider.categories.map((category) => {
146 | if (!category) return null;
147 | return (
148 |
152 |
153 | {category.name}
154 |
155 |
156 | );
157 | })}
158 |
159 |
160 |
164 |
165 |
Services Offered
166 |
167 |
168 |
169 | See info about services offered
170 |
171 |
172 | Click on each service card to see more info.
173 |
174 |
175 |
176 |
177 | {provider.services_offered.map((service) => (
178 |
183 |
189 | {service.pricing.plans.find((pl) =>
190 | pl.name.toLowerCase().includes("free"),
191 | ) && (
192 |
193 | Free Tier
194 |
195 | )}
196 |
197 |
198 | {service.name}
199 |
200 |
201 | {service.description ??
202 | "No description provided for this service."}
203 |
204 |
205 |
206 | {service.supported_types && (
207 | <>
208 |
209 | {service.supported_types.slice(0, 5).map((type) => (
210 |
211 | {type}
212 |
213 | ))}
214 | {service.supported_types.length > 5 && (
215 |
216 | +{service.supported_types.length - 5} more
217 |
218 | )}
219 |
220 |
221 | {service.supported_types.slice(0, 3).map((type) => (
222 |
223 | {type}
224 |
225 | ))}
226 |
227 | {service.supported_types?.length > 3 && (
228 |
232 | +{service.supported_types?.length - 3} more
233 |
234 | )}
235 |
236 | >
237 | )}
238 |
239 |
240 |
241 | ))}
242 |
243 |
244 | {/*
248 |
249 |
Tutorials / Documentation
250 |
251 |
252 |
253 |

258 |
259 |
Lorem ipsum dolor
260 |
261 | Lorem ipsum dolor sit amet consectetur adipisicing elit. bla bla
262 | elefante javascri.
263 |
264 |
265 |
266 |
267 | */}
268 |
269 | );
270 | }
271 |
--------------------------------------------------------------------------------
/src/app/providers/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function ProvidersLayout({
2 | children,
3 | }: {
4 | children: React.ReactNode;
5 | }) {
6 | return {children};
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/providers/page.tsx:
--------------------------------------------------------------------------------
1 | import { ProviderList } from "@/components/providers/list";
2 | import { Button } from "@/components/ui/button";
3 | import {
4 | PageHeader,
5 | PageHeaderActions,
6 | PageHeaderDescription,
7 | PageHeaderTitle,
8 | } from "@/components/ui/page-header";
9 | import { requestUrl } from "@/config";
10 | import { queries } from "@/lib/groq-queries";
11 | import { client } from "@/sanity/lib/client";
12 | import type { Provider } from "@/types/provider";
13 | import { ArrowUpRight } from "lucide-react";
14 | import Link from "next/link";
15 |
16 | export const metadata = {
17 | title: "Providers",
18 | description: "Search and compare free and paid providers.",
19 | };
20 | export const revalidate = 60;
21 | export const dynamic = "force-dynamic";
22 |
23 | export default async function ProvidersPage() {
24 | const providers = await client.fetch(queries.allProviders);
25 | return (
26 |
27 |
28 | Providers
29 |
30 | Take a look at the list of providers we've gathered info on.
31 |
32 |
33 |
49 |
50 |
51 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/app/studio/[[...index]]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import config from "@/../sanity.config";
4 | /**
5 | * This route is responsible for the built-in authoring environment using Sanity Studio.
6 | * All routes under your studio path is handled by this file using Next.js' catch-all routes:
7 | * https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes
8 | *
9 | * You can learn more about the next-sanity package here:
10 | * https://github.com/sanity-io/next-sanity
11 | */
12 | import { NextStudio } from "next-sanity/studio";
13 |
14 | export default function StudioPage() {
15 | return (
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/auth.ts:
--------------------------------------------------------------------------------
1 | import { Lucia, type User, type Session } from "lucia";
2 | import { LibSQLAdapter } from "@lucia-auth/adapter-sqlite";
3 | import { GitHub } from "arctic";
4 | import { client } from "./db";
5 | import { cookies } from "next/headers";
6 | import { cache } from "react";
7 |
8 | const adapter = new LibSQLAdapter(client, {
9 | user: "user",
10 | session: "session",
11 | });
12 |
13 | export const github = new GitHub(
14 | // biome-ignore lint/style/noNonNullAssertion:
15 | process.env.GITHUB_CLIENT_ID!,
16 | // biome-ignore lint/style/noNonNullAssertion:
17 | process.env.GITHUB_CLIENT_SECRET!
18 | );
19 |
20 | export const lucia = new Lucia(adapter, {
21 | sessionCookie: {
22 | expires: false,
23 | attributes: {
24 | secure: process.env.NODE_ENV === "production",
25 | },
26 | },
27 | getUserAttributes: (attributes) => {
28 | return {
29 | // attributes has the type of DatabaseUserAttributes
30 | githubId: attributes.github_id,
31 | username: attributes.username,
32 | avatar_url: attributes.avatar_url,
33 | };
34 | },
35 | });
36 |
37 | export const getSession = cache(
38 | async (): Promise<
39 | { user: User; session: Session } | { user: null; session: null }
40 | > => {
41 | const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
42 | if (!sessionId) {
43 | return {
44 | user: null,
45 | session: null,
46 | };
47 | }
48 |
49 | const result = await lucia.validateSession(sessionId);
50 | // next.js throws when you attempt to set cookie when rendering page
51 | try {
52 | if (result.session?.fresh) {
53 | const sessionCookie = lucia.createSessionCookie(result.session.id);
54 | (await cookies()).set(
55 | sessionCookie.name,
56 | sessionCookie.value,
57 | sessionCookie.attributes
58 | );
59 | }
60 | if (!result.session) {
61 | const sessionCookie = lucia.createBlankSessionCookie();
62 | (await cookies()).set(
63 | sessionCookie.name,
64 | sessionCookie.value,
65 | sessionCookie.attributes
66 | );
67 | }
68 | } catch {}
69 | return result;
70 | }
71 | );
72 |
73 | declare module "lucia" {
74 | interface Register {
75 | Lucia: typeof lucia;
76 | }
77 | }
78 |
79 | declare module "lucia" {
80 | interface Register {
81 | Lucia: typeof lucia;
82 | DatabaseUserAttributes: DatabaseUserAttributes;
83 | }
84 | }
85 |
86 | interface DatabaseUserAttributes {
87 | github_id: number;
88 | username: string;
89 | avatar_url: string;
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NoHaxito/deploys-top/6092c6bde67aa1100cf3b07b338b1f50d2f56835/src/components/header.tsx
--------------------------------------------------------------------------------
/src/components/icons/github.tsx:
--------------------------------------------------------------------------------
1 | import type { SVGProps } from "react";
2 |
3 | export const GithubIcon = (props: SVGProps) => (
4 |
15 | );
16 |
--------------------------------------------------------------------------------
/src/components/menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Link from "next/link";
3 | import { usePathname } from "next/navigation";
4 | import React from "react";
5 |
6 | import { cn, isAwsProvider } from "@/lib/utils";
7 | import type { Provider } from "@/types/provider";
8 | import { Sparkles } from "lucide-react";
9 | import { Badge } from "./ui/badge";
10 | import { buttonVariants } from "./ui/button";
11 |
12 | export function DesktopMenu({
13 | providers,
14 | mostVoted,
15 | }: { providers: Provider[]; mostVoted: Provider | null }) {
16 | const first6FreeProviders = providers.slice(0, 6);
17 |
18 | return (
19 |
20 |
21 | {mostVoted && (
22 |
26 |
{mostVoted?.name}
27 |
28 | {mostVoted?.description}
29 |
30 |
39 |
40 |
41 | Most Rated
42 |
43 |
44 | )}
45 |
46 |
47 |
67 |
68 | );
69 | }
70 |
71 | export const ListItem = React.forwardRef<
72 | React.ElementRef<"a">,
73 | React.ComponentPropsWithoutRef<"a"> & {
74 | providerIcon?: string; // because its a image (url)
75 | }
76 | >(({ className, title, children, providerIcon, href, ...props }, ref) => {
77 | const pathname = usePathname();
78 | return (
79 |
80 |
88 | href={href!}
89 | {...props}
90 | >
91 |
92 | {providerIcon && (
93 |

101 | )}
102 |
103 |
104 |
105 | {title}
106 |
107 |
108 | {children}
109 |
110 |
111 |
112 |
113 |
114 | );
115 | });
116 | ListItem.displayName = "ListItem";
117 |
--------------------------------------------------------------------------------
/src/components/new-header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import type { Provider } from "@/types/provider";
5 | import Image from "next/image";
6 | import Link from "next/link";
7 | import { type HTMLAttributes, useEffect, useState } from "react";
8 | import { DesktopMenu } from "./menu";
9 | import { SearchMenu } from "./search-menu";
10 | import {
11 | NavigationMenu,
12 | NavigationMenuContent,
13 | NavigationMenuItem,
14 | NavigationMenuList,
15 | NavigationMenuTrigger,
16 | NavigationMenuViewport,
17 | } from "./ui/custom-navigation-menu";
18 |
19 | import { getRandomMostVotedProvider } from "@/actions/get-random-most-voted-provider";
20 | import { queries } from "@/lib/groq-queries";
21 | import { client } from "@/sanity/lib/client";
22 | import { GithubIcon } from "./icons/github";
23 | import { ThemeToggle } from "./theme-toggle";
24 | import { buttonVariants } from "./ui/button";
25 |
26 | const Logo = () => (
27 |
32 |
40 | Deploys.top
41 |
42 | );
43 |
44 | const HeaderActions = () => (
45 |
46 |
47 |
57 |
58 |
59 |
60 | );
61 |
62 | export function Navbar(props: HTMLAttributes) {
63 | const [value, setValue] = useState("");
64 | const [providers, setProviders] = useState([]);
65 | const [mostVoted, setMostVoted] = useState(null);
66 | useEffect(() => {
67 | async function getFreeProviders() {
68 | const ps = await client.fetch(queries.freeProviders);
69 | return ps;
70 | }
71 | getFreeProviders().then((data) => {
72 | setProviders(data);
73 | });
74 | }, []);
75 | useEffect(() => {
76 | async function getRandomProvider() {
77 | const provider = await getRandomMostVotedProvider();
78 | return provider;
79 | }
80 | getRandomProvider().then((mostVoted) => {
81 | setMostVoted(JSON.parse(mostVoted) as Provider);
82 | });
83 | }, []);
84 | return (
85 |
86 | 0 ? "shadow-lg" : "shadow-sm",
92 | "bg-background/80 backdrop-blur-lg",
93 | props.className,
94 | )}
95 | >
96 |
100 |
117 |
118 |
119 |
120 |
121 | );
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/progress-bar-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AppProgressBar as NProgressBar } from "next-nprogress-bar";
4 |
5 | export function ProgressBar() {
6 | return (
7 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/provider-header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { useIntersectionObserver } from "@/lib/hooks/use-intersection-observer";
5 | import { cn, isAwsProvider } from "@/lib/utils";
6 | import type { Provider } from "@/types/provider";
7 | import {
8 | LucideArrowUpRight,
9 | LucideChevronLeft,
10 | LucideDollarSign,
11 | LucideGlobe,
12 | LucideThumbsDown,
13 | LucideThumbsUp,
14 | } from "lucide-react";
15 | import Link from "next/link";
16 | import { Separator } from "./ui/separator";
17 | import { ProviderBottomBar } from "./providers/provider-bottom-bar";
18 | import type { User } from "lucia";
19 | import type { VoteTable } from "@/db";
20 | import { useMemo, useTransition } from "react";
21 | import { vote } from "@/app/providers/[provider]/actions";
22 | import { useRouter } from "next/navigation";
23 | import { toast } from "sonner";
24 |
25 | function formatQuantity(value: number) {
26 | if (value < 1000) return value.toString();
27 | const suffixes = ["", "K", "M", "B", "T"];
28 | const suffixNum = Math.floor(`${value}`.length / 3);
29 | let shortValue = Number.parseFloat(
30 | (suffixNum !== 0 ? value / 1000 ** suffixNum : value).toPrecision(2),
31 | );
32 | if (shortValue % 1 !== 0) {
33 | shortValue = Number.parseFloat(shortValue.toFixed(1));
34 | }
35 | return shortValue + suffixes[suffixNum];
36 | }
37 |
38 | export function ProviderHeader({
39 | provider,
40 | user,
41 | votes,
42 | }: { provider: Provider; user: User | null; votes: VoteTable[] }) {
43 | const { isIntersecting, ref } = useIntersectionObserver({
44 | threshold: 0.5,
45 | initialIsIntersecting: true,
46 | });
47 | const router = useRouter();
48 | const [upvotePending, startUpvoteTransition] = useTransition();
49 | const [downvotePending, startDownvoteTransition] = useTransition();
50 | const { downvotes, upvotes, userVote } = useMemo(() => {
51 | const upvotes = votes.filter((vote) => vote.vote_type === "upvote").length;
52 | const downvotes = votes.filter(
53 | (vote) => vote.vote_type === "downvote",
54 | ).length;
55 | const userVote = votes.find((vote) => vote.user_id === user?.id)?.vote_type;
56 | return { upvotes, downvotes, userVote };
57 | }, [votes]);
58 |
59 | return (
60 | <>
61 |
65 |

73 |
74 | {provider.name}
75 |
76 |
77 | {provider.description}
78 |
79 |
80 |
90 |
91 |
102 |
114 |
115 |
116 |
117 |
118 |
140 |
141 |
163 |
164 |
165 |
166 |
167 |
168 | >
169 | );
170 | }
171 |
--------------------------------------------------------------------------------
/src/components/provider-service-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Accordion,
5 | AccordionContent,
6 | AccordionItem,
7 | AccordionTrigger,
8 | } from "@/components/ui/accordion";
9 | import { Button } from "@/components/ui/button";
10 | import { useMediaQuery } from "@/lib/hooks/use-media-query";
11 | import { cn } from "@/lib/utils";
12 | import type { Provider, ServiceOffered } from "@/types/provider";
13 | import { CheckIcon, LucideArrowUpRight, XIcon } from "lucide-react";
14 | import Link from "next/link";
15 | import * as React from "react";
16 | import { Badge } from "./ui/badge";
17 | import { Card, CardContent } from "./ui/card";
18 | import {
19 | Credenza,
20 | CredenzaClose,
21 | CredenzaContent,
22 | CredenzaDescription,
23 | CredenzaFooter,
24 | CredenzaHeader,
25 | CredenzaTitle,
26 | CredenzaTrigger,
27 | } from "./ui/credenza";
28 |
29 | export function ProviderServiceDialog({
30 | children,
31 | provider,
32 | service,
33 | }: {
34 | children: React.ReactNode;
35 | provider: Provider;
36 | service: ServiceOffered;
37 | }) {
38 | const [open, setOpen] = React.useState(false);
39 | const isDesktop = useMediaQuery("(min-width: 640px)");
40 |
41 | return (
42 |
43 |
44 | {children}
45 |
46 |
53 |
54 |
55 | {service.supported_types?.map((type) => (
56 |
61 | {type}
62 |
63 | ))}
64 |
65 |
66 |
67 | {service.name}
68 |
69 |
70 | View information about the{" "}
71 |
72 | {service.name}
73 | {" "}
74 | service offered by{" "}
75 |
76 | {provider.name}
77 |
78 | .
79 |
80 |
81 |
82 |
83 |
84 | {open && (
85 |
92 | {service.pricing.plans.map((plan) => (
93 |
98 |
99 |
100 |
101 | {plan.name}
102 |
103 |
104 |
105 |
106 |
107 | {plan.plan_features.map((feature) => (
108 |
112 |
116 |
117 |
118 | {feature.name}
119 |
120 |
121 |
122 |
123 | {feature.values.map(
124 | ({ key, value }, index: number) => (
125 |
132 |
133 | {key.replaceAll("_", " ")}
134 |
135 |
136 | {value.toLowerCase() === "true" ? (
137 |
138 | ) : value.toLowerCase() === "false" ? (
139 |
140 | ) : (
141 |
142 | {value}
143 |
144 | )}
145 |
146 | ),
147 | )}
148 |
149 |
150 |
151 |
152 | ))}
153 |
154 |
155 |
156 | ))}
157 |
158 | )}
159 |
160 |
161 |
162 |
163 |
170 |
171 | {service.service_pricing_url && (
172 |
178 |
187 |
188 | )}
189 |
190 |
191 |
192 | );
193 | }
194 |
--------------------------------------------------------------------------------
/src/components/providers-carousel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { Provider } from "@/types/provider";
3 | import {
4 | Carousel,
5 | CarouselContent,
6 | CarouselItem,
7 | } from "@/components/ui/carousel";
8 | import Autoplay from "embla-carousel-autoplay";
9 | export function ProvidersCarousel({ providers }: { providers: Provider[] }) {
10 | return (
11 |
25 |
26 | {providers.slice(0, 7).map((provider) => (
27 |
31 |
37 |
38 | ))}
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/providers/filter-popover.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { Button } from "../ui/button";
3 | import {
4 | Command,
5 | CommandEmpty,
6 | CommandGroup,
7 | CommandInput,
8 | CommandItem,
9 | CommandList,
10 | CommandSeparator,
11 | } from "../ui/command";
12 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
13 | import type { Category } from "@/types/category";
14 | import type { Provider } from "@/types/provider";
15 |
16 | import {
17 | LucideCheck,
18 | LucideCircleDollarSign,
19 | LucideFilter,
20 | } from "lucide-react";
21 | import { LucideIcon } from "@/lib/lucide-icon";
22 | import { useState } from "react";
23 |
24 | interface FilterPopoverProps {
25 | isFiltering: boolean;
26 | categories: Category[];
27 | providers: Provider[];
28 | query: string;
29 | category: string[];
30 | freeProviders: boolean;
31 | handleCategoryChange: (
32 | id: string,
33 | action: { type: "add" | "remove" },
34 | ) => void;
35 | handleFreeProvidersChange: (value: boolean | undefined) => void;
36 | handleResetFilters: () => void;
37 | }
38 |
39 | interface CheckboxProps {
40 | isSelected: boolean;
41 | children: React.ReactNode;
42 | }
43 |
44 | const FilterCheckbox = ({ isSelected }: { isSelected: boolean }) => (
45 |
53 |
54 |
55 | );
56 |
57 | const FilterButton = ({ isFiltering }: { isFiltering: boolean }) => (
58 |
70 | );
71 |
72 | const FreeTierFilter = ({
73 | freeProviders,
74 | providers,
75 | handleFreeProvidersChange,
76 | }: Pick<
77 | FilterPopoverProps,
78 | "freeProviders" | "providers" | "handleFreeProvidersChange"
79 | >) => (
80 | handleFreeProvidersChange(freeProviders)}>
81 |
82 |
83 | Free tier
84 |
85 | {providers.filter((p) => p.has_free_tier).length}
86 |
87 |
88 | );
89 |
90 | const CategoryFilter = ({
91 | cat,
92 | isSelected,
93 | providers,
94 | handleCategoryChange,
95 | }: {
96 | cat: Category;
97 | isSelected: boolean;
98 | providers: Provider[];
99 | handleCategoryChange: FilterPopoverProps["handleCategoryChange"];
100 | }) => (
101 | {
104 | handleCategoryChange(cat.id, {
105 | type: isSelected ? "remove" : "add",
106 | });
107 | }}
108 | >
109 |
110 |
111 | {cat.name}
112 |
113 | {
114 | providers
115 | .flatMap((provider) => provider.categories)
116 | .filter((c) => c.id === cat.id).length
117 | }
118 |
119 |
120 | );
121 |
122 | export function FilterPopover({
123 | isFiltering,
124 | categories,
125 | providers,
126 | query,
127 | category,
128 | freeProviders,
129 | handleCategoryChange,
130 | handleFreeProvidersChange,
131 | handleResetFilters,
132 | }: FilterPopoverProps) {
133 | const [open, setOpen] = useState(false);
134 |
135 | return (
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 | No results found.
145 |
146 |
151 | {categories.map((cat) => (
152 |
159 | ))}
160 |
161 | {isFiltering && (
162 |
163 |
164 |
165 |
169 | Clear filters
170 |
171 |
172 |
173 | )}
174 |
175 |
176 |
177 |
178 | );
179 | }
180 |
--------------------------------------------------------------------------------
/src/components/providers/list.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { Provider } from "@/types/provider";
4 | import { LucideSearchX } from "lucide-react";
5 | import { useMemo } from "react";
6 | import { ProviderCard } from "./provider-card";
7 | import { ProviderToolbar } from "./toolbar";
8 | import { AnimatedGroup } from "../ui/animated-group";
9 | import {
10 | parseAsArrayOf,
11 | parseAsBoolean,
12 | parseAsString,
13 | useQueryStates,
14 | } from "nuqs";
15 |
16 | export function ProviderList({ providers }: { providers: Provider[] }) {
17 | const [{ query, category, freeProviders }] = useQueryStates({
18 | query: parseAsString.withDefault(""),
19 | category: parseAsArrayOf(parseAsString).withDefault([]),
20 | freeProviders: parseAsBoolean.withDefault(false),
21 | });
22 |
23 | const filteredProviders = useMemo(() => {
24 | return providers.filter((provider) => {
25 | const categoryFilterPassed =
26 | category.length === 0 ||
27 | category.every((categoryId) => {
28 | return provider.categories.some((c) => c.id === categoryId);
29 | });
30 | const freeProviderFilterPassed = !freeProviders || provider.has_free_tier;
31 | const queryFilterPassed =
32 | !query || provider.name.toLowerCase().includes(query.toLowerCase());
33 |
34 | return (
35 | categoryFilterPassed && queryFilterPassed && freeProviderFilterPassed
36 | );
37 | });
38 | }, [providers, query, category, freeProviders]);
39 |
40 | return (
41 | <>
42 | {providers.length !== 0 ? (
43 | <>
44 |
45 | {filteredProviders.length !== 0 ? (
46 |
73 | {filteredProviders.map((provider) => (
74 |
75 | ))}
76 |
77 | ) : (
78 |
79 |
80 |
No results
81 |
82 | Try with another search parameter or filter.
83 |
84 |
85 | )}
86 | >
87 | ) : (
88 | <>No providers found {/* Add a UI State for this. */}>
89 | )}
90 | >
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/providers/provider-bottom-bar.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import type { Provider } from "@/types/provider";
3 | import { Button } from "../ui/button";
4 | import { LucideChevronLeft, LucideDollarSign, LucideGlobe } from "lucide-react";
5 | import Link from "next/link";
6 |
7 | export function ProviderBottomBar({
8 | show,
9 | provider,
10 | }: { show: boolean; provider: Provider }) {
11 | return (
12 |
18 |
19 |
20 |
29 |

34 |
{provider.name}
35 |
36 |
37 |
46 |
56 |
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/providers/provider-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Popover,
6 | PopoverContent,
7 | PopoverTrigger,
8 | } from "@/components/ui/popover";
9 | import { cn, isAwsProvider } from "@/lib/utils";
10 | import type { Provider } from "@/types/provider";
11 | import { LucideStars } from "lucide-react";
12 | import Link from "next/link";
13 | import type React from "react";
14 | import { useRef, useState } from "react";
15 |
16 | export function ProviderCard({ provider }: { provider: Provider }) {
17 | const linkRef = useRef(null);
18 | const [isFocused, setIsFocused] = useState(false);
19 | const [position, setPosition] = useState({ x: 0, y: 0 });
20 | const [opacity, setOpacity] = useState(0);
21 |
22 | const handleMouseMove = (e: React.MouseEvent) => {
23 | if (!linkRef.current || isFocused) return;
24 |
25 | const link = linkRef.current;
26 | const rect = link.getBoundingClientRect();
27 |
28 | setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
29 | };
30 |
31 | const handleFocus = () => {
32 | setIsFocused(true);
33 | setOpacity(1);
34 | };
35 |
36 | const handleBlur = () => {
37 | setIsFocused(false);
38 | setOpacity(0);
39 | };
40 |
41 | const handleMouseEnter = () => {
42 | setOpacity(1);
43 | };
44 |
45 | const handleMouseLeave = () => {
46 | setOpacity(0);
47 | };
48 |
49 | return (
50 |
59 |
63 |
70 |
77 |

86 |
87 |
{provider.name}
88 |
89 | {provider.description}
90 |
91 |
92 |
93 | {provider.good_free_tier && (
94 |
95 |
96 |
106 |
107 |
108 | Good Free Tier
109 |
110 |
111 | )}
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/providers/toolbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type { Category } from "@/types/category";
4 | import type { Provider } from "@/types/provider";
5 | import { LucideFilterX, LucideLoader2, LucideSearch } from "lucide-react";
6 | import { useTransition } from "react";
7 | import { Button } from "../ui/button";
8 | import { Input } from "../ui/input";
9 | import { FilterPopover } from "./filter-popover";
10 | import {
11 | parseAsArrayOf,
12 | parseAsBoolean,
13 | parseAsString,
14 | useQueryState,
15 | } from "nuqs";
16 |
17 | export interface Filter {
18 | query: string;
19 | category: string[];
20 | freeProviders: boolean;
21 | }
22 |
23 | export function ProviderToolbar({
24 | providers,
25 | }: {
26 | providers: Provider[];
27 | }) {
28 | const [isPending, startTransition] = useTransition();
29 | const [query, setQuery] = useQueryState(
30 | "query",
31 | parseAsString.withDefault(""),
32 | );
33 | const [category, setCategory] = useQueryState(
34 | "category",
35 | parseAsArrayOf(parseAsString).withDefault([]),
36 | );
37 | const [freeProviders, setFreeProviders] = useQueryState(
38 | "freeProviders",
39 | parseAsBoolean.withDefault(false),
40 | );
41 |
42 | const categories = providers.flatMap((provider) =>
43 | provider.categories.map((category) => category),
44 | );
45 |
46 | const isFiltering = query !== "" || category.length > 0 || freeProviders;
47 |
48 | const uniqueCategories: Category[] = Array.from(
49 | new Set(categories.map((category) => JSON.stringify(category))),
50 | ).map((category) => JSON.parse(category));
51 |
52 | const handleQueryChange = (e: React.ChangeEvent) => {
53 | startTransition(() => {
54 | setQuery(e.target.value || null);
55 | });
56 | };
57 |
58 | const handleFreeProvidersChange = (isSelected?: boolean) => {
59 | startTransition(() => {
60 | setFreeProviders(!isSelected);
61 | });
62 | };
63 |
64 | const handleCategoryChange = (
65 | categoryId: string,
66 | options?: { type: "add" | "remove" },
67 | ) => {
68 | startTransition(() => {
69 | if (options?.type === "add") {
70 | setCategory([...category, categoryId]);
71 | } else {
72 | setCategory(category.filter((c) => c !== categoryId));
73 | }
74 | });
75 | };
76 |
77 | const handleResetFilters = () => {
78 | startTransition(() => {
79 | setQuery(null);
80 | setCategory(null);
81 | setFreeProviders(null);
82 | });
83 | };
84 |
85 | return (
86 |
87 |
88 |
89 |
90 |
91 |
97 |
98 | {isPending && (
99 |
100 |
101 |
102 | )}
103 |
104 |
105 | {isFiltering && (
106 |
116 | )}
117 |
118 |
129 |
130 |
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/src/components/search-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Check,
5 | LucideArrowRight,
6 | LucideCommand,
7 | LucideGrid2X2,
8 | LucideLoader2,
9 | LucideMonitor,
10 | LucideMoon,
11 | LucideSearch,
12 | LucideSun,
13 | } from "lucide-react";
14 | import * as React from "react";
15 |
16 | import {
17 | CommandDialog,
18 | CommandEmpty,
19 | CommandGroup,
20 | CommandInput,
21 | CommandItem,
22 | CommandList,
23 | CommandSeparator,
24 | } from "@/components/ui/command";
25 | import { requestUrl } from "@/config";
26 | import { queries } from "@/lib/groq-queries";
27 | import { client } from "@/sanity/lib/client";
28 | import type { Provider } from "@/types/provider";
29 | import { useTheme } from "next-themes";
30 | import Image from "next/image";
31 | import { useRouter } from "next/navigation";
32 | import { GithubIcon } from "./icons/github";
33 | import { Button } from "./ui/button";
34 |
35 | export function SearchMenu() {
36 | const router = useRouter();
37 | const [open, setOpen] = React.useState(false);
38 | const [providers, setProviders] = React.useState([]);
39 | const [loading, setLoading] = React.useState(false);
40 | const [search, setSearch] = React.useState("");
41 | const [pages, setPages] = React.useState([]);
42 | const { theme, setTheme } = useTheme();
43 | const page = React.useMemo(() => pages[pages.length - 1], [pages]);
44 |
45 | const fetchProviders = React.useCallback(async () => {
46 | try {
47 | setLoading(true);
48 | const providers = await client.fetch(queries.allProviders);
49 | setProviders(providers);
50 | } catch (error) {
51 | console.error("Failed to fetch providers:", error);
52 | } finally {
53 | setLoading(false);
54 | }
55 | }, []);
56 |
57 | React.useEffect(() => {
58 | if (open) {
59 | fetchProviders();
60 | }
61 | }, [open, fetchProviders]);
62 |
63 | React.useEffect(() => {
64 | const down = (e: KeyboardEvent) => {
65 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
66 | e.preventDefault();
67 | setOpen((open) => !open);
68 | }
69 | };
70 |
71 | document.addEventListener("keydown", down);
72 | return () => document.removeEventListener("keydown", down);
73 | }, []);
74 |
75 | const resetSearch = () => {
76 | setSearch("");
77 | setPages([]);
78 | };
79 |
80 | return (
81 | <>
82 |
93 | {
96 | setOpen(open);
97 | if (!open) resetSearch();
98 | }}
99 | >
100 |
106 |
107 |
108 |
109 | No results found.
110 |
111 | Try searching for a provider name or feature.
112 |
113 |
114 | {!page && (
115 | <>
116 |
117 | setPages([...pages, "providers"])}
119 | className="flex items-center gap-2 transition-colors"
120 | >
121 |
122 | Search Providers
123 |
124 |
125 | {
127 | window.open(requestUrl, "_blank");
128 | }}
129 | className="flex items-center gap-2"
130 | >
131 |
132 | Request Provider
133 |
134 | Opens GitHub
135 |
136 |
137 |
138 |
139 |
140 | setTheme("light")}
142 | className="flex items-center gap-2"
143 | >
144 |
145 | Light
146 | {theme === "light" && (
147 |
148 |
149 |
150 | )}
151 |
152 | setTheme("dark")}
154 | className="flex items-center gap-2"
155 | >
156 |
157 | Dark
158 | {theme === "dark" && (
159 |
160 |
161 |
162 | )}
163 |
164 | setTheme("system")}
166 | className="flex items-center gap-2"
167 | >
168 |
169 | System
170 | {theme === "system" && (
171 |
172 |
173 |
174 | )}
175 |
176 |
177 | >
178 | )}
179 | {page === "providers" && (
180 |
181 | {loading ? (
182 |
183 |
184 |
185 | ) : (
186 | providers.map((provider) => (
187 | {
189 | router.push(`/providers/${provider.id}`);
190 | setOpen(false);
191 | }}
192 | key={provider.id}
193 | value={`${provider.name} ${provider.description}`}
194 | className="group flex items-center gap-3 py-3 transition-colors"
195 | >
196 |
197 |

202 |
203 |
204 |
205 | {provider.name}
206 |
207 |
208 | {provider.description}
209 |
210 |
211 |
212 | ))
213 | )}
214 |
215 | )}
216 |
217 |
218 |
219 |
226 |
227 | {providers.length} providers available
228 |
229 |
230 |
231 |
232 | ↑↓
233 |
234 |
235 | to navigate
236 |
237 |
238 | enter
239 |
240 |
241 | to select
242 |
243 |
244 | esc
245 |
246 | to close
247 |
248 |
249 |
250 | >
251 | );
252 | }
253 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
3 | import type { ThemeProviderProps } from "next-themes/dist/types";
4 |
5 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
6 | return {children};
7 | }
8 | export { useTheme };
9 |
--------------------------------------------------------------------------------
/src/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "@/components/theme-provider";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { LucideMonitor, LucideMoon, LucideSun, Moon, Sun } from "lucide-react";
12 |
13 | export function ThemeToggle() {
14 | const { setTheme } = useTheme();
15 | return (
16 |
17 |
18 |
27 |
28 |
29 | setTheme("light")}>
30 |
31 | Light
32 |
33 | setTheme("dark")}>
34 |
35 | Dark
36 |
37 | setTheme("system")}>
38 |
39 | System
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDownIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Accordion({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function AccordionItem({
16 | className,
17 | ...props
18 | }: React.ComponentProps) {
19 | return (
20 |
25 | )
26 | }
27 |
28 | function AccordionTrigger({
29 | className,
30 | children,
31 | ...props
32 | }: React.ComponentProps) {
33 | return (
34 |
35 | svg]:rotate-180",
39 | className
40 | )}
41 | {...props}
42 | >
43 | {children}
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | function AccordionContent({
51 | className,
52 | children,
53 | ...props
54 | }: React.ComponentProps) {
55 | return (
56 |
61 | {children}
62 |
63 | )
64 | }
65 |
66 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
67 |
--------------------------------------------------------------------------------
/src/components/ui/animated-group.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { type Variants, motion } from "framer-motion";
3 | import type { ReactNode } from "react";
4 | import React from "react";
5 |
6 | export type PresetType =
7 | | "fade"
8 | | "slide"
9 | | "scale"
10 | | "blur"
11 | | "blur-slide"
12 | | "zoom"
13 | | "flip"
14 | | "bounce"
15 | | "rotate"
16 | | "swing";
17 |
18 | export type AnimatedGroupProps = {
19 | children: ReactNode;
20 | className?: string;
21 | variants?: {
22 | container?: Variants;
23 | item?: Variants;
24 | };
25 | preset?: PresetType;
26 | as?: React.ElementType;
27 | asChild?: React.ElementType;
28 | };
29 |
30 | const defaultContainerVariants: Variants = {
31 | visible: {
32 | transition: {
33 | staggerChildren: 0.1,
34 | },
35 | },
36 | };
37 |
38 | const defaultItemVariants: Variants = {
39 | hidden: { opacity: 0 },
40 | visible: { opacity: 1 },
41 | };
42 |
43 | const presetVariants: Record = {
44 | fade: {},
45 | slide: {
46 | hidden: { y: 20 },
47 | visible: { y: 0 },
48 | },
49 | scale: {
50 | hidden: { scale: 0.8 },
51 | visible: { scale: 1 },
52 | },
53 | blur: {
54 | hidden: { filter: "blur(4px)" },
55 | visible: { filter: "blur(0px)" },
56 | },
57 | "blur-slide": {
58 | hidden: { filter: "blur(4px)", y: 20 },
59 | visible: { filter: "blur(0px)", y: 0 },
60 | },
61 | zoom: {
62 | hidden: { scale: 0.5 },
63 | visible: {
64 | scale: 1,
65 | transition: { type: "spring", stiffness: 300, damping: 20 },
66 | },
67 | },
68 | flip: {
69 | hidden: { rotateX: -90 },
70 | visible: {
71 | rotateX: 0,
72 | transition: { type: "spring", stiffness: 300, damping: 20 },
73 | },
74 | },
75 | bounce: {
76 | hidden: { y: -50 },
77 | visible: {
78 | y: 0,
79 | transition: { type: "spring", stiffness: 400, damping: 10 },
80 | },
81 | },
82 | rotate: {
83 | hidden: { rotate: -180 },
84 | visible: {
85 | rotate: 0,
86 | transition: { type: "spring", stiffness: 200, damping: 15 },
87 | },
88 | },
89 | swing: {
90 | hidden: { rotate: -10 },
91 | visible: {
92 | rotate: 0,
93 | transition: { type: "spring", stiffness: 300, damping: 8 },
94 | },
95 | },
96 | };
97 |
98 | const addDefaultVariants = (variants: Variants) => ({
99 | hidden: { ...defaultItemVariants.hidden, ...variants.hidden },
100 | visible: { ...defaultItemVariants.visible, ...variants.visible },
101 | });
102 |
103 | function AnimatedGroup({
104 | children,
105 | className,
106 | variants,
107 | preset,
108 | as = "div",
109 | asChild = "div",
110 | }: AnimatedGroupProps) {
111 | const selectedVariants = {
112 | item: addDefaultVariants(preset ? presetVariants[preset] : {}),
113 | container: addDefaultVariants(defaultContainerVariants),
114 | };
115 | const containerVariants = variants?.container || selectedVariants.container;
116 | const itemVariants = variants?.item || selectedVariants.item;
117 |
118 | const MotionComponent = React.useMemo(() => motion(as), [as]);
119 | const MotionChild = React.useMemo(() => motion(asChild), [asChild]);
120 |
121 | return (
122 |
128 | {React.Children.map(children, (child, i) => (
129 |
132 | i
133 | }`}
134 | variants={itemVariants}
135 | >
136 | {child}
137 |
138 | ))}
139 |
140 | );
141 | }
142 |
143 | export { AnimatedGroup };
144 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { type VariantProps, cva } from "class-variance-authority";
2 | import type * as React from "react";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | },
24 | );
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | );
34 | }
35 |
36 | export { Badge, badgeVariants };
37 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { Slot, Slottable } from "@radix-ui/react-slot";
3 | import { type VariantProps, cva } from "class-variance-authority";
4 | import * as React from "react";
5 |
6 | const buttonVariants = cva(
7 | "inline-flex items-center gap-2 justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
12 | destructive:
13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
14 | outline:
15 | "border border-input hover:bg-accent hover:text-accent-foreground",
16 | secondary:
17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
18 | ghost: "hover:bg-accent hover:text-accent-foreground",
19 | link: "text-primary underline-offset-4 hover:underline",
20 | expandIcon:
21 | "group relative text-primary-foreground bg-primary hover:bg-primary/90",
22 | ringHover:
23 | "bg-primary text-primary-foreground transition-all duration-300 hover:bg-primary/90 hover:ring-2 hover:ring-primary/90 hover:ring-offset-2",
24 | shine:
25 | "text-primary-foreground animate-shine bg-linear-to-r from-primary via-primary/75 to-primary bg-[length:400%_100%] ",
26 | gooeyRight:
27 | "text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 before:absolute before:inset-0 before:-z-10 before:translate-x-[150%] before:translate-y-[150%] before:scale-[2.5] before:rounded-[100%] before:bg-linear-to-r from-zinc-400 before:transition-transform before:duration-1000 hover:before:translate-x-[0%] hover:before:translate-y-[0%] ",
28 | gooeyLeft:
29 | "text-primary-foreground relative bg-primary z-0 overflow-hidden transition-all duration-500 after:absolute after:inset-0 after:-z-10 after:translate-x-[-150%] after:translate-y-[150%] after:scale-[2.5] after:rounded-[100%] after:bg-linear-to-l from-zinc-400 after:transition-transform after:duration-1000 hover:after:translate-x-[0%] hover:after:translate-y-[0%] ",
30 | linkHover1:
31 | "relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
32 | linkHover2:
33 | "relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
34 | },
35 | size: {
36 | default: "h-10 px-4 py-2",
37 | sm: "h-9 rounded-md px-3",
38 | lg: "h-11 rounded-md px-8",
39 | icon: "h-10 w-10",
40 | },
41 | },
42 | defaultVariants: {
43 | variant: "default",
44 | size: "default",
45 | },
46 | },
47 | );
48 |
49 | interface IconProps {
50 | Icon: React.ElementType;
51 | iconPlacement: "left" | "right";
52 | }
53 |
54 | interface IconRefProps {
55 | Icon?: never;
56 | iconPlacement?: undefined;
57 | }
58 |
59 | export interface ButtonProps
60 | extends React.ButtonHTMLAttributes,
61 | VariantProps {
62 | asChild?: boolean;
63 | }
64 |
65 | export type ButtonIconProps = IconProps | IconRefProps;
66 |
67 | const Button = React.forwardRef<
68 | HTMLButtonElement,
69 | ButtonProps & ButtonIconProps
70 | >(
71 | (
72 | {
73 | className,
74 | variant,
75 | size,
76 | asChild = false,
77 | Icon,
78 | iconPlacement,
79 | ...props
80 | },
81 | ref,
82 | ) => {
83 | const Comp = asChild ? Slot : "button";
84 | return (
85 |
90 | {Icon && iconPlacement === "left" && (
91 |
92 |
93 |
94 | )}
95 | {props.children}
96 | {Icon && iconPlacement === "right" && (
97 |
98 |
99 |
100 | )}
101 |
102 | );
103 | },
104 | );
105 | Button.displayName = "Button";
106 |
107 | export { Button, buttonVariants };
108 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = "CardTitle";
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = "CardDescription";
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = "CardContent";
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = "CardFooter";
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/src/components/ui/carousel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import useEmblaCarousel, {
5 | type UseEmblaCarouselType,
6 | } from "embla-carousel-react";
7 | import { ArrowLeft, ArrowRight } from "lucide-react";
8 |
9 | import { cn } from "@/lib/utils";
10 | import { Button } from "@/components/ui/button";
11 |
12 | type CarouselApi = UseEmblaCarouselType[1];
13 | type UseCarouselParameters = Parameters;
14 | type CarouselOptions = UseCarouselParameters[0];
15 | type CarouselPlugin = UseCarouselParameters[1];
16 |
17 | type CarouselProps = {
18 | opts?: CarouselOptions;
19 | plugins?: CarouselPlugin;
20 | orientation?: "horizontal" | "vertical";
21 | setApi?: (api: CarouselApi) => void;
22 | };
23 |
24 | type CarouselContextProps = {
25 | carouselRef: ReturnType[0];
26 | api: ReturnType[1];
27 | scrollPrev: () => void;
28 | scrollNext: () => void;
29 | canScrollPrev: boolean;
30 | canScrollNext: boolean;
31 | } & CarouselProps;
32 |
33 | const CarouselContext = React.createContext(null);
34 |
35 | function useCarousel() {
36 | const context = React.useContext(CarouselContext);
37 |
38 | if (!context) {
39 | throw new Error("useCarousel must be used within a ");
40 | }
41 |
42 | return context;
43 | }
44 |
45 | const Carousel = React.forwardRef<
46 | HTMLDivElement,
47 | React.HTMLAttributes & CarouselProps
48 | >(
49 | (
50 | {
51 | orientation = "horizontal",
52 | opts,
53 | setApi,
54 | plugins,
55 | className,
56 | children,
57 | ...props
58 | },
59 | ref,
60 | ) => {
61 | const [carouselRef, api] = useEmblaCarousel(
62 | {
63 | ...opts,
64 | axis: orientation === "horizontal" ? "x" : "y",
65 | },
66 | plugins,
67 | );
68 | const [canScrollPrev, setCanScrollPrev] = React.useState(false);
69 | const [canScrollNext, setCanScrollNext] = React.useState(false);
70 |
71 | const onSelect = React.useCallback((api: CarouselApi) => {
72 | if (!api) {
73 | return;
74 | }
75 |
76 | setCanScrollPrev(api.canScrollPrev());
77 | setCanScrollNext(api.canScrollNext());
78 | }, []);
79 |
80 | const scrollPrev = React.useCallback(() => {
81 | api?.scrollPrev();
82 | }, [api]);
83 |
84 | const scrollNext = React.useCallback(() => {
85 | api?.scrollNext();
86 | }, [api]);
87 |
88 | const handleKeyDown = React.useCallback(
89 | (event: React.KeyboardEvent) => {
90 | if (event.key === "ArrowLeft") {
91 | event.preventDefault();
92 | scrollPrev();
93 | } else if (event.key === "ArrowRight") {
94 | event.preventDefault();
95 | scrollNext();
96 | }
97 | },
98 | [scrollPrev, scrollNext],
99 | );
100 |
101 | React.useEffect(() => {
102 | if (!api || !setApi) {
103 | return;
104 | }
105 |
106 | setApi(api);
107 | }, [api, setApi]);
108 |
109 | React.useEffect(() => {
110 | if (!api) {
111 | return;
112 | }
113 |
114 | onSelect(api);
115 | api.on("reInit", onSelect);
116 | api.on("select", onSelect);
117 |
118 | return () => {
119 | api?.off("select", onSelect);
120 | api?.off("reInit", onSelect);
121 | };
122 | }, [api, onSelect]);
123 |
124 | return (
125 |
138 |
146 | {children}
147 |
148 |
149 | );
150 | },
151 | );
152 | Carousel.displayName = "Carousel";
153 |
154 | const CarouselContent = React.forwardRef<
155 | HTMLDivElement,
156 | React.HTMLAttributes
157 | >(({ className, ...props }, ref) => {
158 | const { carouselRef, orientation } = useCarousel();
159 |
160 | return (
161 |
172 | );
173 | });
174 | CarouselContent.displayName = "CarouselContent";
175 |
176 | const CarouselItem = React.forwardRef<
177 | HTMLDivElement,
178 | React.HTMLAttributes
179 | >(({ className, ...props }, ref) => {
180 | const { orientation } = useCarousel();
181 |
182 | return (
183 |
194 | );
195 | });
196 | CarouselItem.displayName = "CarouselItem";
197 |
198 | const CarouselPrevious = React.forwardRef<
199 | HTMLButtonElement,
200 | React.ComponentProps
201 | >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
202 | const { orientation, scrollPrev, canScrollPrev } = useCarousel();
203 |
204 | return (
205 |
223 | );
224 | });
225 | CarouselPrevious.displayName = "CarouselPrevious";
226 |
227 | const CarouselNext = React.forwardRef<
228 | HTMLButtonElement,
229 | React.ComponentProps
230 | >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
231 | const { orientation, scrollNext, canScrollNext } = useCarousel();
232 |
233 | return (
234 |
252 | );
253 | });
254 | CarouselNext.displayName = "CarouselNext";
255 |
256 | export {
257 | type CarouselApi,
258 | Carousel,
259 | CarouselContent,
260 | CarouselItem,
261 | CarouselPrevious,
262 | CarouselNext,
263 | };
264 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { CheckIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Checkbox({
10 | className,
11 | ...props
12 | }: React.ComponentProps) {
13 | return (
14 |
22 |
26 |
27 |
28 |
29 | )
30 | }
31 |
32 | export { Checkbox }
33 |
--------------------------------------------------------------------------------
/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Dialog, DialogContent } from "@/components/ui/dialog";
4 | import { cn } from "@/lib/utils";
5 | import type { DialogProps } from "@radix-ui/react-dialog";
6 | import { Command as CommandPrimitive } from "cmdk";
7 | import { Search } from "lucide-react";
8 | import * as React from "react";
9 |
10 | const Command = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | Command.displayName = CommandPrimitive.displayName;
24 |
25 | interface CommandDialogProps extends DialogProps {
26 | paginated?: boolean;
27 | onKeyDown?: (e: React.KeyboardEvent) => void;
28 | }
29 |
30 | const CommandDialog = ({
31 | children,
32 | paginated,
33 | onKeyDown,
34 | ...props
35 | }: CommandDialogProps) => {
36 | return (
37 |
47 | );
48 | };
49 |
50 | const CommandInput = React.forwardRef<
51 | React.ElementRef,
52 | React.ComponentPropsWithoutRef
53 | >(({ className, ...props }, ref) => (
54 |
55 |
56 |
64 |
65 | ));
66 |
67 | CommandInput.displayName = CommandPrimitive.Input.displayName;
68 |
69 | const CommandList = React.forwardRef<
70 | React.ElementRef,
71 | React.ComponentPropsWithoutRef
72 | >(({ className, ...props }, ref) => (
73 |
78 | ));
79 |
80 | CommandList.displayName = CommandPrimitive.List.displayName;
81 |
82 | const CommandEmpty = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >((props, ref) => (
86 |
91 | ));
92 |
93 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
94 |
95 | const CommandGroup = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
107 | ));
108 |
109 | CommandGroup.displayName = CommandPrimitive.Group.displayName;
110 |
111 | const CommandSeparator = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
120 | ));
121 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
122 |
123 | const CommandItem = React.forwardRef<
124 | React.ElementRef,
125 | React.ComponentPropsWithoutRef
126 | >(({ className, ...props }, ref) => (
127 |
135 | ));
136 |
137 | CommandItem.displayName = CommandPrimitive.Item.displayName;
138 |
139 | const CommandShortcut = ({
140 | className,
141 | ...props
142 | }: React.HTMLAttributes) => {
143 | return (
144 |
151 | );
152 | };
153 | CommandShortcut.displayName = "CommandShortcut";
154 |
155 | export {
156 | Command,
157 | CommandDialog,
158 | CommandInput,
159 | CommandList,
160 | CommandEmpty,
161 | CommandGroup,
162 | CommandItem,
163 | CommandShortcut,
164 | CommandSeparator,
165 | };
166 |
--------------------------------------------------------------------------------
/src/components/ui/credenza.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import type * as React from "react";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { useMediaQuery } from "@/lib/hooks/use-media-query";
7 | import {
8 | Dialog,
9 | DialogClose,
10 | DialogContent,
11 | DialogDescription,
12 | DialogFooter,
13 | DialogHeader,
14 | DialogTitle,
15 | DialogTrigger,
16 | } from "@/components/ui/dialog";
17 | import {
18 | Drawer,
19 | DrawerClose,
20 | DrawerContent,
21 | DrawerDescription,
22 | DrawerFooter,
23 | DrawerHeader,
24 | DrawerTitle,
25 | DrawerTrigger,
26 | } from "@/components/ui/drawer";
27 |
28 | interface BaseProps {
29 | children: React.ReactNode;
30 | }
31 |
32 | interface RootCredenzaProps extends BaseProps {
33 | open?: boolean;
34 | onOpenChange?: (open: boolean) => void;
35 | }
36 |
37 | interface CredenzaProps extends BaseProps {
38 | className?: string;
39 | asChild?: true;
40 | }
41 |
42 | const desktop = "(min-width: 640px)";
43 |
44 | const Credenza = ({ children, ...props }: RootCredenzaProps) => {
45 | const isDesktop = useMediaQuery(desktop);
46 | const Credenza = isDesktop ? Dialog : Drawer;
47 |
48 | return {children};
49 | };
50 |
51 | const CredenzaTrigger = ({ className, children, ...props }: CredenzaProps) => {
52 | const isDesktop = useMediaQuery(desktop);
53 | const CredenzaTrigger = isDesktop ? DialogTrigger : DrawerTrigger;
54 |
55 | return (
56 |
57 | {children}
58 |
59 | );
60 | };
61 |
62 | const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
63 | const isDesktop = useMediaQuery(desktop);
64 | const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
65 |
66 | return (
67 |
68 | {children}
69 |
70 | );
71 | };
72 |
73 | const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
74 | const isDesktop = useMediaQuery(desktop);
75 | const CredenzaContent = isDesktop ? DialogContent : DrawerContent;
76 |
77 | return (
78 |
79 | {children}
80 |
81 | );
82 | };
83 |
84 | const CredenzaDescription = ({
85 | className,
86 | children,
87 | ...props
88 | }: CredenzaProps) => {
89 | const isDesktop = useMediaQuery(desktop);
90 | const CredenzaDescription = isDesktop ? DialogDescription : DrawerDescription;
91 |
92 | return (
93 |
94 | {children}
95 |
96 | );
97 | };
98 |
99 | const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
100 | const isDesktop = useMediaQuery(desktop);
101 | const CredenzaHeader = isDesktop ? DialogHeader : DrawerHeader;
102 |
103 | return (
104 |
105 | {children}
106 |
107 | );
108 | };
109 |
110 | const CredenzaTitle = ({ className, children, ...props }: CredenzaProps) => {
111 | const isDesktop = useMediaQuery(desktop);
112 | const CredenzaTitle = isDesktop ? DialogTitle : DrawerTitle;
113 |
114 | return (
115 |
116 | {children}
117 |
118 | );
119 | };
120 |
121 | const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
122 | return (
123 |
124 | {children}
125 |
126 | );
127 | };
128 |
129 | const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
130 | const isDesktop = useMediaQuery(desktop);
131 | const CredenzaFooter = isDesktop ? DialogFooter : DrawerFooter;
132 |
133 | return (
134 |
135 | {children}
136 |
137 | );
138 | };
139 |
140 | export {
141 | Credenza,
142 | CredenzaTrigger,
143 | CredenzaClose,
144 | CredenzaContent,
145 | CredenzaDescription,
146 | CredenzaHeader,
147 | CredenzaTitle,
148 | CredenzaBody,
149 | CredenzaFooter,
150 | };
151 |
--------------------------------------------------------------------------------
/src/components/ui/custom-navigation-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import * as Primitive from "@radix-ui/react-navigation-menu";
4 | import { cn } from "@/lib/utils";
5 |
6 | const NavigationMenu = Primitive.Root;
7 |
8 | const NavigationMenuList = Primitive.List;
9 |
10 | const NavigationMenuItem = React.forwardRef<
11 | React.ComponentRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, children, ...props }, ref) => (
14 |
19 | {children}
20 |
21 | ));
22 |
23 | NavigationMenuItem.displayName = Primitive.NavigationMenuItem.displayName;
24 |
25 | const NavigationMenuTrigger = React.forwardRef<
26 | React.ComponentRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, children, ...props }, ref) => (
29 |
34 | {children}
35 |
36 | ));
37 | NavigationMenuTrigger.displayName = Primitive.Trigger.displayName;
38 |
39 | const NavigationMenuContent = React.forwardRef<
40 | React.ComponentRef,
41 | React.ComponentPropsWithoutRef
42 | >(({ className, ...props }, ref) => (
43 |
51 | ));
52 | NavigationMenuContent.displayName = Primitive.Content.displayName;
53 |
54 | const NavigationMenuLink = Primitive.Link;
55 |
56 | const NavigationMenuViewport = React.forwardRef<
57 | React.ComponentRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
69 | ));
70 | NavigationMenuViewport.displayName = Primitive.Viewport.displayName;
71 |
72 | export {
73 | NavigationMenu,
74 | NavigationMenuList,
75 | NavigationMenuItem,
76 | NavigationMenuContent,
77 | NavigationMenuTrigger,
78 | NavigationMenuLink,
79 | NavigationMenuViewport,
80 | };
81 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as DialogPrimitive from "@radix-ui/react-dialog";
4 | import { XIcon } from "lucide-react";
5 | import type * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return ;
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return ;
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return ;
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return ;
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | );
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | ...props
53 | }: React.ComponentProps) {
54 | return (
55 |
56 |
57 |
65 | {children}
66 |
67 |
68 | Close
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
76 | return (
77 |
82 | );
83 | }
84 |
85 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
86 | return (
87 |
95 | );
96 | }
97 |
98 | function DialogTitle({
99 | className,
100 | ...props
101 | }: React.ComponentProps) {
102 | return (
103 |
108 | );
109 | }
110 |
111 | function DialogDescription({
112 | className,
113 | ...props
114 | }: React.ComponentProps) {
115 | return (
116 |
121 | );
122 | }
123 |
124 | export {
125 | Dialog,
126 | DialogClose,
127 | DialogContent,
128 | DialogDescription,
129 | DialogFooter,
130 | DialogHeader,
131 | DialogOverlay,
132 | DialogPortal,
133 | DialogTitle,
134 | DialogTrigger,
135 | };
136 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Drawer({
9 | ...props
10 | }: React.ComponentProps) {
11 | return
12 | }
13 |
14 | function DrawerTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return
18 | }
19 |
20 | function DrawerPortal({
21 | ...props
22 | }: React.ComponentProps) {
23 | return
24 | }
25 |
26 | function DrawerClose({
27 | ...props
28 | }: React.ComponentProps) {
29 | return
30 | }
31 |
32 | function DrawerOverlay({
33 | className,
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
45 | )
46 | }
47 |
48 | function DrawerContent({
49 | className,
50 | children,
51 | ...props
52 | }: React.ComponentProps) {
53 | return (
54 |
55 |
56 |
68 |
69 | {children}
70 |
71 |
72 | )
73 | }
74 |
75 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
76 | return (
77 |
82 | )
83 | }
84 |
85 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
86 | return (
87 |
92 | )
93 | }
94 |
95 | function DrawerTitle({
96 | className,
97 | ...props
98 | }: React.ComponentProps) {
99 | return (
100 |
105 | )
106 | }
107 |
108 | function DrawerDescription({
109 | className,
110 | ...props
111 | }: React.ComponentProps) {
112 | return (
113 |
118 | )
119 | }
120 |
121 | export {
122 | Drawer,
123 | DrawerPortal,
124 | DrawerOverlay,
125 | DrawerTrigger,
126 | DrawerClose,
127 | DrawerContent,
128 | DrawerHeader,
129 | DrawerFooter,
130 | DrawerTitle,
131 | DrawerDescription,
132 | }
133 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function DropdownMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DropdownMenuPortal({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function DropdownMenuTrigger({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
31 | )
32 | }
33 |
34 | function DropdownMenuContent({
35 | className,
36 | sideOffset = 4,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 |
51 | )
52 | }
53 |
54 | function DropdownMenuGroup({
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
59 | )
60 | }
61 |
62 | function DropdownMenuItem({
63 | className,
64 | inset,
65 | variant = "default",
66 | ...props
67 | }: React.ComponentProps & {
68 | inset?: boolean
69 | variant?: "default" | "destructive"
70 | }) {
71 | return (
72 |
82 | )
83 | }
84 |
85 | function DropdownMenuCheckboxItem({
86 | className,
87 | children,
88 | checked,
89 | ...props
90 | }: React.ComponentProps) {
91 | return (
92 |
101 |
102 |
103 |
104 |
105 |
106 | {children}
107 |
108 | )
109 | }
110 |
111 | function DropdownMenuRadioGroup({
112 | ...props
113 | }: React.ComponentProps) {
114 | return (
115 |
119 | )
120 | }
121 |
122 | function DropdownMenuRadioItem({
123 | className,
124 | children,
125 | ...props
126 | }: React.ComponentProps) {
127 | return (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | )
144 | }
145 |
146 | function DropdownMenuLabel({
147 | className,
148 | inset,
149 | ...props
150 | }: React.ComponentProps & {
151 | inset?: boolean
152 | }) {
153 | return (
154 |
163 | )
164 | }
165 |
166 | function DropdownMenuSeparator({
167 | className,
168 | ...props
169 | }: React.ComponentProps) {
170 | return (
171 |
176 | )
177 | }
178 |
179 | function DropdownMenuShortcut({
180 | className,
181 | ...props
182 | }: React.ComponentProps<"span">) {
183 | return (
184 |
192 | )
193 | }
194 |
195 | function DropdownMenuSub({
196 | ...props
197 | }: React.ComponentProps) {
198 | return
199 | }
200 |
201 | function DropdownMenuSubTrigger({
202 | className,
203 | inset,
204 | children,
205 | ...props
206 | }: React.ComponentProps & {
207 | inset?: boolean
208 | }) {
209 | return (
210 |
219 | {children}
220 |
221 |
222 | )
223 | }
224 |
225 | function DropdownMenuSubContent({
226 | className,
227 | ...props
228 | }: React.ComponentProps) {
229 | return (
230 |
238 | )
239 | }
240 |
241 | export {
242 | DropdownMenu,
243 | DropdownMenuPortal,
244 | DropdownMenuTrigger,
245 | DropdownMenuContent,
246 | DropdownMenuGroup,
247 | DropdownMenuLabel,
248 | DropdownMenuItem,
249 | DropdownMenuCheckboxItem,
250 | DropdownMenuRadioGroup,
251 | DropdownMenuRadioItem,
252 | DropdownMenuSeparator,
253 | DropdownMenuShortcut,
254 | DropdownMenuSub,
255 | DropdownMenuSubTrigger,
256 | DropdownMenuSubContent,
257 | }
258 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/src/components/ui/navigation-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
3 | import { cva } from "class-variance-authority"
4 | import { ChevronDownIcon } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function NavigationMenu({
9 | className,
10 | children,
11 | viewport = true,
12 | ...props
13 | }: React.ComponentProps & {
14 | viewport?: boolean
15 | }) {
16 | return (
17 |
26 | {children}
27 | {viewport && }
28 |
29 | )
30 | }
31 |
32 | function NavigationMenuList({
33 | className,
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
45 | )
46 | }
47 |
48 | function NavigationMenuItem({
49 | className,
50 | ...props
51 | }: React.ComponentProps) {
52 | return (
53 |
58 | )
59 | }
60 |
61 | const navigationMenuTriggerStyle = cva(
62 | "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
63 | )
64 |
65 | function NavigationMenuTrigger({
66 | className,
67 | children,
68 | ...props
69 | }: React.ComponentProps) {
70 | return (
71 |
76 | {children}{" "}
77 |
81 |
82 | )
83 | }
84 |
85 | function NavigationMenuContent({
86 | className,
87 | ...props
88 | }: React.ComponentProps) {
89 | return (
90 |
99 | )
100 | }
101 |
102 | function NavigationMenuViewport({
103 | className,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
112 |
120 |
121 | )
122 | }
123 |
124 | function NavigationMenuLink({
125 | className,
126 | ...props
127 | }: React.ComponentProps) {
128 | return (
129 |
137 | )
138 | }
139 |
140 | function NavigationMenuIndicator({
141 | className,
142 | ...props
143 | }: React.ComponentProps) {
144 | return (
145 |
153 |
154 |
155 | )
156 | }
157 |
158 | export {
159 | NavigationMenu,
160 | NavigationMenuList,
161 | NavigationMenuItem,
162 | NavigationMenuContent,
163 | NavigationMenuTrigger,
164 | NavigationMenuLink,
165 | NavigationMenuIndicator,
166 | NavigationMenuViewport,
167 | navigationMenuTriggerStyle,
168 | }
169 |
--------------------------------------------------------------------------------
/src/components/ui/page-header.tsx:
--------------------------------------------------------------------------------
1 | import { pickChildren } from "@/lib/children";
2 | import { cn } from "@/lib/utils";
3 | import type React from "react";
4 |
5 | function PageHeader({
6 | className,
7 | children,
8 | ...props
9 | }: React.HTMLAttributes) {
10 | const [childrenWithoutActions, actions] = pickChildren(
11 | children,
12 | PageHeaderActions,
13 | );
14 | return (
15 |
22 |
{childrenWithoutActions}
23 | {actions}
24 |
25 | );
26 | }
27 |
28 | function PageHeaderTitle({
29 | icon,
30 | className,
31 | ...props
32 | }: React.HTMLAttributes & {
33 | icon?: React.ReactNode;
34 | }) {
35 | return (
36 |
37 | {icon && icon}
38 |
45 |
46 | );
47 | }
48 |
49 | function PageHeaderDescription({
50 | className,
51 | ...props
52 | }: React.HTMLAttributes) {
53 | return (
54 |
58 | );
59 | }
60 |
61 | function PageHeaderActions({
62 | className,
63 | ...props
64 | }: React.HTMLAttributes) {
65 | return (
66 |
70 | );
71 | }
72 |
73 | export {
74 | PageHeader,
75 | PageHeaderTitle,
76 | PageHeaderDescription,
77 | PageHeaderActions,
78 | };
79 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Popover({
9 | ...props
10 | }: React.ComponentProps) {
11 | return
12 | }
13 |
14 | function PopoverTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return
18 | }
19 |
20 | function PopoverContent({
21 | className,
22 | align = "center",
23 | sideOffset = 4,
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
28 |
38 |
39 | )
40 | }
41 |
42 | function PopoverAnchor({
43 | ...props
44 | }: React.ComponentProps) {
45 | return
46 | }
47 |
48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
49 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function ScrollArea({
9 | className,
10 | children,
11 | ...props
12 | }: React.ComponentProps) {
13 | return (
14 |
19 |
23 | {children}
24 |
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | function ScrollBar({
32 | className,
33 | orientation = "vertical",
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
50 |
54 |
55 | )
56 | }
57 |
58 | export { ScrollArea, ScrollBar }
59 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }: React.ComponentProps & {
33 | size?: "sm" | "default"
34 | }) {
35 | return (
36 |
45 | {children}
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | function SelectContent({
54 | className,
55 | children,
56 | position = "popper",
57 | ...props
58 | }: React.ComponentProps) {
59 | return (
60 |
61 |
72 |
73 |
80 | {children}
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | function SelectLabel({
89 | className,
90 | ...props
91 | }: React.ComponentProps) {
92 | return (
93 |
98 | )
99 | }
100 |
101 | function SelectItem({
102 | className,
103 | children,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
115 |
116 |
117 |
118 |
119 |
120 | {children}
121 |
122 | )
123 | }
124 |
125 | function SelectSeparator({
126 | className,
127 | ...props
128 | }: React.ComponentProps) {
129 | return (
130 |
135 | )
136 | }
137 |
138 | function SelectScrollUpButton({
139 | className,
140 | ...props
141 | }: React.ComponentProps) {
142 | return (
143 |
151 |
152 |
153 | )
154 | }
155 |
156 | function SelectScrollDownButton({
157 | className,
158 | ...props
159 | }: React.ComponentProps) {
160 | return (
161 |
169 |
170 |
171 | )
172 | }
173 |
174 | export {
175 | Select,
176 | SelectContent,
177 | SelectGroup,
178 | SelectItem,
179 | SelectLabel,
180 | SelectScrollDownButton,
181 | SelectScrollUpButton,
182 | SelectSeparator,
183 | SelectTrigger,
184 | SelectValue,
185 | }
186 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Separator({
9 | className,
10 | orientation = "horizontal",
11 | decorative = true,
12 | ...props
13 | }: React.ComponentProps) {
14 | return (
15 |
25 | )
26 | }
27 |
28 | export { Separator }
29 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Tabs({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 | )
19 | }
20 |
21 | function TabsList({
22 | className,
23 | ...props
24 | }: React.ComponentProps) {
25 | return (
26 |
34 | )
35 | }
36 |
37 | function TabsTrigger({
38 | className,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
50 | )
51 | }
52 |
53 | function TabsContent({
54 | className,
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
63 | )
64 | }
65 |
66 | export { Tabs, TabsList, TabsTrigger, TabsContent }
67 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function TooltipProvider({
9 | delayDuration = 0,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
18 | )
19 | }
20 |
21 | function Tooltip({
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
26 |
27 |
28 | )
29 | }
30 |
31 | function TooltipTrigger({
32 | ...props
33 | }: React.ComponentProps) {
34 | return
35 | }
36 |
37 | function TooltipContent({
38 | className,
39 | sideOffset = 0,
40 | children,
41 | ...props
42 | }: React.ComponentProps) {
43 | return (
44 |
45 |
54 | {children}
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
62 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | export const requestUrl =
2 | "https://github.com/NoHaxito/deploys-top/issues/new?assignees=&labels=provider+request&projects=&template=request-provider.md&title=%5B%E2%9E%95%5D+Provider+Request";
3 |
--------------------------------------------------------------------------------
/src/db.ts:
--------------------------------------------------------------------------------
1 | import { Kysely } from "kysely";
2 | import { LibsqlDialect } from "@libsql/kysely-libsql";
3 | import { createClient } from "@libsql/client";
4 |
5 | interface Database {
6 | user: UserTable;
7 | session: SessionTable;
8 | vote: VoteTable;
9 | }
10 |
11 | interface UserTable {
12 | id: string;
13 | username: string;
14 | avatar_url: string;
15 | github_id: number;
16 | }
17 |
18 | interface SessionTable {
19 | id: string;
20 | user_id: string;
21 | expires_at: Date;
22 | }
23 |
24 | export interface VoteTable {
25 | vote_id?: number;
26 | user_id: string;
27 | provider_id: string;
28 | vote_type: "upvote" | "downvote";
29 | }
30 |
31 | export const client = createClient({
32 | // biome-ignore lint/style/noNonNullAssertion:
33 | url: process.env.DATABASE_URL!,
34 | // biome-ignore lint/style/noNonNullAssertion:
35 | authToken: process.env.DATABASE_AUTH_TOKEN!,
36 | });
37 |
38 | export const db = new Kysely({
39 | dialect: new LibsqlDialect({
40 | url: process.env.DATABASE_URL,
41 | authToken: process.env.DATABASE_AUTH_TOKEN,
42 | }),
43 | });
44 |
--------------------------------------------------------------------------------
/src/fonts/InterVariable.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NoHaxito/deploys-top/6092c6bde67aa1100cf3b07b338b1f50d2f56835/src/fonts/InterVariable.woff2
--------------------------------------------------------------------------------
/src/lib/children.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Taked from https://github.com/nextui-org/nextui/blob/main/packages/utilities/react-rsc-utils/src/children.ts
3 | * All credits to the respective author
4 | */
5 | import { Children, type ReactNode, isValidElement } from "react";
6 |
7 | /**
8 | * Gets only the valid children of a component,
9 | * and ignores any nullish or falsy child.
10 | *
11 | * @param children the children
12 | */
13 | export function getValidChildren(children: React.ReactNode) {
14 | return Children.toArray(children).filter((child) =>
15 | isValidElement(child),
16 | ) as React.ReactElement[];
17 | }
18 |
19 | export const pickChildren = (
20 | children: T | undefined,
21 | targetChild: React.ElementType,
22 | ): [T | undefined, T[] | undefined] => {
23 | const target: T[] = [];
24 |
25 | const withoutTargetChildren = Children.map(children, (item) => {
26 | if (!isValidElement(item)) return item;
27 | if (item.type === targetChild) {
28 | target.push(item as T);
29 |
30 | return null;
31 | }
32 |
33 | return item;
34 | })?.filter(Boolean) as T;
35 |
36 | const targetChildren = target.length >= 0 ? target : undefined;
37 |
38 | return [withoutTargetChildren, targetChildren];
39 | };
40 |
--------------------------------------------------------------------------------
/src/lib/groq-queries.ts:
--------------------------------------------------------------------------------
1 | import { groq } from "next-sanity";
2 |
3 | export const queries = {
4 | allProviders: groq`*[_type == "provider"]{ id, good_free_tier, has_free_tier, name, description, href, pricing_href, is_serverless, "categories": categories[]->{id, name, icon}, icon, services_offered }`,
5 | getProvider: groq`*[_type == "provider" && id == $id][0]{ id, good_free_tier, has_free_tier, name, description, href, pricing_href, is_serverless, "categories": categories[]->{id, name, icon}, icon, services_offered }`,
6 | serverlessProviders: groq`*[_type == "provider" && is_serverless]{ id, good_free_tier, has_free_tier, name, description, href, pricing_href, is_serverless, "categories": categories[]->{id, name, icon}, icon, services_offered }`,
7 | freeProviders: groq`*[_type == "provider" && has_free_tier]{ id, good_free_tier, has_free_tier, name, description, href, pricing_href, is_serverless, "categories": categories[]->{id, name, icon}, icon, services_offered }`,
8 | allCategories: groq`*[_type == "category"]{ id, name, icon }`,
9 | };
10 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-event-listener.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useLayoutEffect } from "react";
2 |
3 | import type { RefObject } from "react";
4 |
5 | export const useIsomorphicLayoutEffect =
6 | typeof window !== "undefined" ? useLayoutEffect : useEffect;
7 |
8 | // MediaQueryList Event based useEventListener interface
9 | function useEventListener(
10 | eventName: K,
11 | handler: (event: MediaQueryListEventMap[K]) => void,
12 | element: RefObject,
13 | options?: boolean | AddEventListenerOptions
14 | ): void;
15 |
16 | // Window Event based useEventListener interface
17 | function useEventListener(
18 | eventName: K,
19 | handler: (event: WindowEventMap[K]) => void,
20 | element?: undefined,
21 | options?: boolean | AddEventListenerOptions
22 | ): void;
23 |
24 | // Element Event based useEventListener interface
25 | function useEventListener<
26 | K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
27 | T extends Element = K extends keyof HTMLElementEventMap
28 | ? HTMLDivElement
29 | : SVGElement,
30 | >(
31 | eventName: K,
32 | handler:
33 | | ((event: HTMLElementEventMap[K]) => void)
34 | | ((event: SVGElementEventMap[K]) => void),
35 | element: RefObject,
36 | options?: boolean | AddEventListenerOptions
37 | ): void;
38 |
39 | // Document Event based useEventListener interface
40 | function useEventListener(
41 | eventName: K,
42 | handler: (event: DocumentEventMap[K]) => void,
43 | element: RefObject,
44 | options?: boolean | AddEventListenerOptions
45 | ): void;
46 |
47 | function useEventListener<
48 | KW extends keyof WindowEventMap,
49 | KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
50 | KM extends keyof MediaQueryListEventMap,
51 | T extends HTMLElement | SVGAElement | MediaQueryList = HTMLElement,
52 | >(
53 | eventName: KW | KH | KM,
54 | handler: (
55 | event:
56 | | WindowEventMap[KW]
57 | | HTMLElementEventMap[KH]
58 | | SVGElementEventMap[KH]
59 | | MediaQueryListEventMap[KM]
60 | | Event
61 | ) => void,
62 | element?: RefObject,
63 | options?: boolean | AddEventListenerOptions
64 | ) {
65 | // Create a ref that stores handler
66 | const savedHandler = useRef(handler);
67 |
68 | useIsomorphicLayoutEffect(() => {
69 | savedHandler.current = handler;
70 | }, [handler]);
71 |
72 | useEffect(() => {
73 | // Define the listening target
74 | const targetElement: T | Window = element?.current ?? window;
75 |
76 | if (!targetElement?.addEventListener) return;
77 |
78 | // Create event listener that calls handler function stored in ref
79 | const listener: typeof handler = (event) => {
80 | savedHandler.current(event);
81 | };
82 |
83 | targetElement.addEventListener(eventName, listener, options);
84 |
85 | // Remove event listener on cleanup
86 | return () => {
87 | targetElement.removeEventListener(eventName, listener, options);
88 | };
89 | }, [eventName, element, options]);
90 | }
91 |
92 | export { useEventListener };
93 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-hover.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | import type { RefObject } from "react";
4 | import { useEventListener } from "./use-event-listener";
5 |
6 | export function useHover(
7 | elementRef: RefObject
8 | ): boolean {
9 | const [value, setValue] = useState(false);
10 |
11 | const handleMouseEnter = () => {
12 | setValue(true);
13 | };
14 | const handleMouseLeave = () => {
15 | setValue(false);
16 | };
17 |
18 | useEventListener("mouseenter", handleMouseEnter, elementRef);
19 | useEventListener("mouseleave", handleMouseLeave, elementRef);
20 |
21 | return value;
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-intersection-observer.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 |
3 | type State = {
4 | isIntersecting: boolean;
5 | entry?: IntersectionObserverEntry;
6 | };
7 |
8 | type UseIntersectionObserverOptions = {
9 | root?: Element | Document | null;
10 | rootMargin?: string;
11 | threshold?: number | number[];
12 | freezeOnceVisible?: boolean;
13 | onChange?: (
14 | isIntersecting: boolean,
15 | entry: IntersectionObserverEntry,
16 | ) => void;
17 | initialIsIntersecting?: boolean;
18 | };
19 |
20 | type IntersectionReturn = [
21 | (node?: Element | null) => void,
22 | boolean,
23 | IntersectionObserverEntry | undefined,
24 | ] & {
25 | ref: (node?: Element | null) => void;
26 | isIntersecting: boolean;
27 | entry?: IntersectionObserverEntry;
28 | };
29 |
30 | export function useIntersectionObserver({
31 | threshold = 0,
32 | root = null,
33 | rootMargin = "0%",
34 | freezeOnceVisible = false,
35 | initialIsIntersecting = false,
36 | onChange,
37 | }: UseIntersectionObserverOptions = {}): IntersectionReturn {
38 | const [ref, setRef] = useState(null);
39 |
40 | const [state, setState] = useState(() => ({
41 | isIntersecting: initialIsIntersecting,
42 | entry: undefined,
43 | }));
44 |
45 | const callbackRef = useRef(null);
46 |
47 | callbackRef.current = onChange;
48 |
49 | const frozen = state.entry?.isIntersecting && freezeOnceVisible;
50 |
51 | useEffect(() => {
52 | // Ensure we have a ref to observe
53 | if (!ref) return;
54 |
55 | // Ensure the browser supports the Intersection Observer API
56 | if (!("IntersectionObserver" in window)) return;
57 |
58 | // Skip if frozen
59 | if (frozen) return;
60 |
61 | let unobserve: (() => void) | undefined;
62 |
63 | const observer = new IntersectionObserver(
64 | (entries: IntersectionObserverEntry[]): void => {
65 | const thresholds = Array.isArray(observer.thresholds)
66 | ? observer.thresholds
67 | : [observer.thresholds];
68 |
69 | entries.forEach((entry) => {
70 | const isIntersecting =
71 | entry.isIntersecting &&
72 | thresholds.some(
73 | (threshold) => entry.intersectionRatio >= threshold,
74 | );
75 |
76 | setState({ isIntersecting, entry });
77 |
78 | if (callbackRef.current) {
79 | callbackRef.current(isIntersecting, entry);
80 | }
81 |
82 | if (isIntersecting && freezeOnceVisible && unobserve) {
83 | unobserve();
84 | unobserve = undefined;
85 | }
86 | });
87 | },
88 | { threshold, root, rootMargin },
89 | );
90 |
91 | observer.observe(ref);
92 |
93 | return () => {
94 | observer.disconnect();
95 | };
96 |
97 | // eslint-disable-next-line react-hooks/exhaustive-deps
98 | }, [
99 | ref,
100 | // eslint-disable-next-line react-hooks/exhaustive-deps
101 | JSON.stringify(threshold),
102 | root,
103 | rootMargin,
104 | frozen,
105 | freezeOnceVisible,
106 | ]);
107 |
108 | // ensures that if the observed element changes, the intersection observer is reinitialized
109 | const prevRef = useRef(null);
110 |
111 | useEffect(() => {
112 | if (
113 | !ref &&
114 | state.entry?.target &&
115 | !freezeOnceVisible &&
116 | !frozen &&
117 | prevRef.current !== state.entry.target
118 | ) {
119 | prevRef.current = state.entry.target;
120 | setState({ isIntersecting: initialIsIntersecting, entry: undefined });
121 | }
122 | }, [ref, state.entry, freezeOnceVisible, frozen, initialIsIntersecting]);
123 |
124 | const result = [
125 | setRef,
126 | !!state.isIntersecting,
127 | state.entry,
128 | ] as IntersectionReturn;
129 |
130 | // Support object destructuring, by adding the specific values.
131 | result.ref = result[0];
132 | result.isIntersecting = result[1];
133 | result.entry = result[2];
134 |
135 | return result;
136 | }
137 |
--------------------------------------------------------------------------------
/src/lib/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useLayoutEffect, useState } from "react";
2 |
3 | export const useIsomorphicLayoutEffect =
4 | typeof window !== "undefined" ? useLayoutEffect : useEffect;
5 | type UseMediaQueryOptions = {
6 | defaultValue?: boolean;
7 | initializeWithValue?: boolean;
8 | };
9 |
10 | const IS_SERVER = typeof window === "undefined";
11 |
12 | export function useMediaQuery(
13 | query: string,
14 | {
15 | defaultValue = false,
16 | initializeWithValue = true,
17 | }: UseMediaQueryOptions = {},
18 | ): boolean {
19 | const getMatches = (query: string): boolean => {
20 | if (IS_SERVER) {
21 | return defaultValue;
22 | }
23 | return window.matchMedia(query).matches;
24 | };
25 |
26 | const [matches, setMatches] = useState(() => {
27 | if (initializeWithValue) {
28 | return getMatches(query);
29 | }
30 | return defaultValue;
31 | });
32 |
33 | // Handles the change event of the media query.
34 | function handleChange() {
35 | setMatches(getMatches(query));
36 | }
37 |
38 | useIsomorphicLayoutEffect(() => {
39 | const matchMedia = window.matchMedia(query);
40 |
41 | // Triggered at the first client-side load and if query changes
42 | handleChange();
43 |
44 | // Use deprecated `addListener` and `removeListener` to support Safari < 14 (#135)
45 | if (matchMedia.addListener) {
46 | matchMedia.addListener(handleChange);
47 | } else {
48 | matchMedia.addEventListener("change", handleChange);
49 | }
50 |
51 | return () => {
52 | if (matchMedia.removeListener) {
53 | matchMedia.removeListener(handleChange);
54 | } else {
55 | matchMedia.removeEventListener("change", handleChange);
56 | }
57 | };
58 | }, [query]);
59 |
60 | return matches;
61 | }
62 |
--------------------------------------------------------------------------------
/src/lib/lucide-icon.tsx:
--------------------------------------------------------------------------------
1 | import { LucideLoader2, type LucideProps } from "lucide-react";
2 | import dynamicIconImports from "lucide-react/dynamicIconImports";
3 | import dynamic from "next/dynamic";
4 | import React from "react";
5 |
6 | interface IconProps extends LucideProps {
7 | name: string;
8 | }
9 | const fallback = (
10 |
11 |
12 |
13 | );
14 |
15 | export const LucideIcon = ({ name, ...props }: IconProps) => {
16 | const Icon = React.useMemo(
17 | () =>
18 | dynamic(dynamicIconImports[name as keyof typeof dynamicIconImports], {
19 | loading: () => fallback,
20 | }),
21 | [],
22 | );
23 |
24 | return ;
25 | };
26 |
--------------------------------------------------------------------------------
/src/lib/search-params.ts:
--------------------------------------------------------------------------------
1 | import { parseAsFloat, createSearchParamsCache } from "nuqs/server";
2 |
3 | export const filtersParser = {
4 | lat: parseAsFloat.withDefault(45.18),
5 | lng: parseAsFloat.withDefault(5.72),
6 | };
7 | export const filtersCache = createSearchParamsCache(filtersParser);
8 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | import type { Provider } from "@/types/provider";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | export function isAwsProvider(providerName: Provider["name"]) {
10 | return providerName === "Amazon Web Services";
11 | }
12 |
--------------------------------------------------------------------------------
/src/sanity/env.ts:
--------------------------------------------------------------------------------
1 | export const apiVersion =
2 | process.env.NEXT_PUBLIC_SANITY_API_VERSION || "2024-04-28";
3 |
4 | export const dataset = assertValue(
5 | process.env.NEXT_PUBLIC_SANITY_DATASET,
6 | "Missing environment variable: NEXT_PUBLIC_SANITY_DATASET",
7 | );
8 |
9 | export const projectId = assertValue(
10 | process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
11 | "Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID",
12 | );
13 |
14 | export const useCdn = false;
15 |
16 | function assertValue(v: T | undefined, errorMessage: string): T {
17 | if (v === undefined) {
18 | throw new Error(errorMessage);
19 | }
20 |
21 | return v;
22 | }
23 |
--------------------------------------------------------------------------------
/src/sanity/lib/client.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from "next-sanity";
2 |
3 | import { apiVersion, dataset, projectId, useCdn } from "../env";
4 |
5 | export const client = createClient({
6 | apiVersion,
7 | dataset,
8 | projectId,
9 | useCdn,
10 | });
11 |
--------------------------------------------------------------------------------
/src/sanity/lib/image.ts:
--------------------------------------------------------------------------------
1 | import createImageUrlBuilder from "@sanity/image-url";
2 | import type { Image } from "sanity";
3 |
4 | import { dataset, projectId } from "../env";
5 |
6 | const imageBuilder = createImageUrlBuilder({
7 | projectId: projectId || "",
8 | dataset: dataset || "",
9 | });
10 |
11 | export const urlForImage = (source: Image) => {
12 | return imageBuilder?.image(source).auto("format").fit("max").url();
13 | };
14 |
--------------------------------------------------------------------------------
/src/sanity/schema.ts:
--------------------------------------------------------------------------------
1 | import type { SchemaTypeDefinition } from "sanity";
2 |
3 | import { categorySchema } from "./schemas/category";
4 | import { providerSchema } from "./schemas/provider";
5 | import { planFeatureSchema, planSchema } from "./schemas/plan";
6 |
7 | export const schema: { types: SchemaTypeDefinition[] } = {
8 | types: [providerSchema, categorySchema, planSchema, planFeatureSchema],
9 | };
10 |
--------------------------------------------------------------------------------
/src/sanity/schemas/category.ts:
--------------------------------------------------------------------------------
1 | import { LucideSquareLibrary } from "lucide-react";
2 | import { defineField, defineType } from "sanity";
3 |
4 | export const categorySchema = defineType({
5 | name: "category",
6 | title: "Category",
7 | icon: LucideSquareLibrary,
8 | type: "document",
9 | fields: [
10 | defineField({
11 | name: "id",
12 | type: "string",
13 | validation: (rule) => rule.required() && rule.lowercase(),
14 | description: "The ID of the category sluggified. Example: category-name",
15 | }),
16 | defineField({
17 | name: "name",
18 | description: "The name of the category",
19 | type: "string",
20 | validation: (rule) => rule.required(),
21 | }),
22 | defineField({
23 | name: "icon",
24 | description:
25 | "The icon is taked from lucide.dev, select an icon and copy the name of the icon, then paste here.",
26 | type: "string",
27 | validation: (rule) => rule.required(),
28 | }),
29 | ],
30 | });
31 |
--------------------------------------------------------------------------------
/src/sanity/schemas/plan.ts:
--------------------------------------------------------------------------------
1 | import { defineType } from "sanity";
2 |
3 | export const planSchema = defineType({
4 | type: "object",
5 | name: "plan",
6 | fields: [
7 | {
8 | type: "string",
9 | name: "name",
10 | description:
11 | "Name of the plan, example: Basic, Free Tier or something like that.",
12 | validation: (rule) => rule.required().warning("Fill this field"),
13 | },
14 | {
15 | type: "array",
16 | name: "plan_features",
17 | of: [
18 | {
19 | type: "plan_features",
20 | },
21 | ],
22 | validation: (rule) => rule.required().warning("Fill this field"),
23 | },
24 | ],
25 | validation: (rule) => rule.required().warning("Fill this field"),
26 | });
27 |
28 | export const planFeatureSchema = defineType({
29 | type: "object",
30 | name: "plan_features",
31 | fields: [
32 | {
33 | type: "string",
34 | name: "name",
35 | description: "Feature name of the plan, example: Bandwith",
36 | validation: (rule) => rule.required().warning("Fill this field"),
37 | },
38 | {
39 | type: "array",
40 | name: "values",
41 | of: [
42 | {
43 | type: "object",
44 | name: "value",
45 | fields: [
46 | {
47 | type: "string",
48 | name: "key",
49 | validation: (rule) => rule.required().error("Fill this field"),
50 | },
51 | {
52 | type: "string",
53 | name: "value",
54 | validation: (rule) => rule.required().error("Fill this field"),
55 | },
56 | ],
57 | },
58 | ],
59 | },
60 | ],
61 | validation: (rule) => rule.required().warning("Fill this field"),
62 | });
63 |
--------------------------------------------------------------------------------
/src/sanity/schemas/provider.ts:
--------------------------------------------------------------------------------
1 | import { LucideLayoutPanelLeft } from "lucide-react";
2 | import { defineField, defineType } from "sanity";
3 |
4 | export const providerSchema = defineType({
5 | name: "provider",
6 | title: "Provider",
7 | icon: LucideLayoutPanelLeft,
8 | type: "document",
9 | fields: [
10 | defineField({
11 | name: "categories",
12 | type: "array",
13 | of: [
14 | defineField({
15 | name: "category_name",
16 | type: "reference",
17 | to: [{ type: "category" }],
18 | }),
19 | ],
20 | validation: (rule) => rule.required().warning("Fill this field"),
21 | }),
22 | defineField({
23 | name: "id",
24 | type: "string",
25 | description:
26 | "Name of the provider in lowercase and slugified. Example: provider-name",
27 | validation: (rule) => rule.required().warning("Fill this field"),
28 | }),
29 | defineField({
30 | name: "name",
31 | type: "string",
32 | validation: (rule) => rule.required().warning("Fill this field"),
33 | }),
34 | defineField({
35 | name: "description",
36 | type: "string",
37 | validation: (rule) => rule.required().warning("Fill this field"),
38 | }),
39 | defineField({
40 | name: "icon",
41 | type: "string",
42 | validation: (rule) => rule.required().warning("Fill this field"),
43 | description: "The url of the provider logo (ask to nohaxito)",
44 | }),
45 | defineField({
46 | name: "href",
47 | type: "url",
48 | validation: (rule) => rule.required().warning("Fill this field"),
49 | description: "The link to the provider page",
50 | }),
51 | defineField({
52 | name: "pricing_href",
53 | type: "url",
54 | validation: (rule) => rule.required().warning("Fill this field"),
55 | description: "The link to the provider pricing page",
56 | }),
57 | defineField({
58 | type: "array",
59 | name: "values",
60 | of: [
61 | {
62 | type: "string",
63 | name: "key",
64 | validation: (rule) => rule.required().warning("Fill this field"),
65 | },
66 | {
67 | type: "string",
68 | name: "value",
69 | validation: (rule) => rule.required().warning("Fill this field"),
70 | },
71 | ],
72 | validation: (rule) => rule.required().warning("Fill this field"),
73 | hidden: true,
74 | }),
75 | defineField({
76 | name: "services_offered",
77 | type: "array",
78 | of: [
79 | {
80 | type: "object",
81 | fields: [
82 | {
83 | name: "category_name",
84 | type: "string",
85 | validation: (rule) => rule.required().warning("Fill this field"),
86 | description:
87 | "This name must be match with the 'id' from the 'Category' document.",
88 | },
89 | {
90 | name: "name",
91 | type: "string",
92 | validation: (rule) => rule.required().warning("Fill this field"),
93 | },
94 | {
95 | type: "url",
96 | name: "service_pricing_url",
97 | description:
98 | "The link to the provider service pricing page (if it exists)",
99 | },
100 | {
101 | name: "description",
102 | type: "string",
103 | },
104 | {
105 | name: "supported_types",
106 | type: "array",
107 | of: [{ type: "string" }],
108 | },
109 | {
110 | name: "pricing",
111 | type: "object",
112 | fields: [
113 | {
114 | name: "free_tier",
115 | type: "array",
116 | of: [
117 | {
118 | type: "object",
119 | description:
120 | "Fill the necessary fields, don't fill with unkown data.",
121 | fields: [
122 | {
123 | name: "type",
124 | type: "string",
125 | validation: (rule) =>
126 | rule.required().warning("Fill this field"),
127 | },
128 | {
129 | name: "included",
130 | type: "string",
131 | validation: (rule) =>
132 | rule.required().warning("Fill this field"),
133 | },
134 | { name: "price_per_gb", type: "string" },
135 | ],
136 | },
137 | ],
138 | },
139 | {
140 | type: "array",
141 | name: "plans",
142 | of: [
143 | {
144 | type: "plan",
145 | },
146 | ],
147 | validation: (rule) =>
148 | rule.required() &&
149 | rule.min(1).warning("Add at least one plan"),
150 | },
151 | ],
152 | },
153 | {
154 | name: "disabled",
155 | type: "boolean",
156 | initialValue: false,
157 | },
158 | ],
159 | },
160 | ],
161 | validation: (rule) => rule.min(1).error("Add at least one service"),
162 | }),
163 | defineField({
164 | name: "has_free_tier",
165 | type: "boolean",
166 | initialValue: false,
167 | description: "If the provider has a free tier.",
168 | }),
169 | defineField({
170 | name: "good_free_tier",
171 | type: "boolean",
172 | initialValue: false,
173 | description: "If the provider has a good free tier.",
174 | }),
175 | defineField({
176 | name: "is_serverless",
177 | type: "boolean",
178 | initialValue: false,
179 | description: "If the provider is serverless.",
180 | }),
181 | ],
182 | });
183 |
--------------------------------------------------------------------------------
/src/styles/animations.css:
--------------------------------------------------------------------------------
1 | @theme {
2 | --animate-fade-in: fade-in 300ms ease;
3 |
4 | --animate-fade-out: fade-out 300ms ease;
5 |
6 | --animate-dialog-in: dialog-in 200ms cubic-bezier(0.32, 0.72, 0, 1);
7 |
8 | --animate-dialog-out: dialog-out 300ms cubic-bezier(0.32, 0.72, 0, 1);
9 |
10 | --animate-popover-in: popover-in 150ms ease;
11 |
12 | --animate-popover-out: popover-out 150ms ease;
13 |
14 | --animate-collapsible-down: collapsible-down 150ms ease-out;
15 |
16 | --animate-collapsible-up: collapsible-up 150ms ease-out;
17 |
18 | --animate-accordion-down: accordion-down 200ms ease-out;
19 |
20 | --animate-accordion-up: accordion-up 200ms ease-out;
21 |
22 | --animate-nav-menu-in: nav-menu-in 200ms ease;
23 |
24 | --animate-nav-menu-out: nav-menu-out 200ms ease;
25 |
26 | --animate-enterFromLeft: enterFromLeft 250ms ease;
27 |
28 | --animate-enterFromRight: enterFromRight 250ms ease;
29 |
30 | --animate-exitToLeft: exitToLeft 250ms ease;
31 |
32 | --animate-exitToRight: exitToRight 250ms ease;
33 |
34 | @keyframes collapsible-down {
35 | from {
36 | height: 0;
37 | opacity: 0;
38 | }
39 | to {
40 | height: var(--radix-collapsible-content-height);
41 | }
42 | }
43 |
44 | @keyframes collapsible-up {
45 | from {
46 | height: var(--radix-collapsible-content-height);
47 | }
48 | to {
49 | height: 0;
50 | opacity: 0;
51 | }
52 | }
53 |
54 | @keyframes accordion-down {
55 | from {
56 | height: 0;
57 | opacity: 0.5;
58 | }
59 | to {
60 | height: var(--radix-accordion-content-height);
61 | }
62 | }
63 |
64 | @keyframes accordion-up {
65 | from {
66 | height: var(--radix-accordion-content-height);
67 | }
68 | to {
69 | height: 0;
70 | opacity: 0.5;
71 | }
72 | }
73 |
74 | @keyframes dialog-in {
75 | from {
76 | transform: scale(0.95);
77 | opacity: 0;
78 | }
79 | to {
80 | transform: scale(1);
81 | }
82 | }
83 |
84 | @keyframes dialog-out {
85 | from {
86 | transform: scale(1);
87 | }
88 | to {
89 | transform: scale(0.95);
90 | opacity: 0;
91 | }
92 | }
93 |
94 | @keyframes popover-in {
95 | from {
96 | opacity: 0;
97 | transform: scale(0.98) translateY(-4px);
98 | }
99 | to {
100 | opacity: 1;
101 | transform: scale(1) translateY(0);
102 | }
103 | }
104 |
105 | @keyframes popover-out {
106 | from {
107 | opacity: 1;
108 | transform: translateY(0);
109 | }
110 | to {
111 | opacity: 0;
112 | transform: translateY(-4px);
113 | }
114 | }
115 |
116 | @keyframes fade-in {
117 | from {
118 | opacity: 0;
119 | }
120 | to {
121 | opacity: 1;
122 | }
123 | }
124 |
125 | @keyframes fade-out {
126 | from {
127 | opacity: 1;
128 | }
129 | to {
130 | opacity: 0;
131 | }
132 | }
133 |
134 | @keyframes enterFromRight {
135 | from {
136 | opacity: 0;
137 | transform: translateX(200px);
138 | }
139 | to {
140 | opacity: 1;
141 | transform: translateX(0);
142 | }
143 | }
144 |
145 | @keyframes enterFromLeft {
146 | from {
147 | opacity: 0;
148 | transform: translateX(-200px);
149 | }
150 | to {
151 | opacity: 1;
152 | transform: translateX(0);
153 | }
154 | }
155 |
156 | @keyframes exitToRight {
157 | from {
158 | opacity: 1;
159 | transform: translateX(0);
160 | }
161 | to {
162 | opacity: 0;
163 | transform: translateX(200px);
164 | }
165 | }
166 |
167 | @keyframes exitToLeft {
168 | from {
169 | opacity: 1;
170 | transform: translateX(0);
171 | }
172 | to {
173 | opacity: 0;
174 | transform: translateX(-200px);
175 | }
176 | }
177 |
178 | @keyframes nav-menu-in {
179 | from {
180 | opacity: 0;
181 | height: 0px;
182 | }
183 | to {
184 | opacity: 1;
185 | height: var(--radix-navigation-menu-viewport-height);
186 | }
187 | }
188 |
189 | @keyframes nav-menu-out {
190 | from {
191 | opacity: 1;
192 | height: var(--radix-navigation-menu-viewport-height);
193 | }
194 | to {
195 | opacity: 0;
196 | height: 0px;
197 | }
198 | }
199 | }
--------------------------------------------------------------------------------
/src/types/category.ts:
--------------------------------------------------------------------------------
1 | export interface Category {
2 | id: string;
3 | name: string;
4 | icon: string;
5 | }
6 |
--------------------------------------------------------------------------------
/src/types/provider.ts:
--------------------------------------------------------------------------------
1 | import type { Category } from "./category";
2 |
3 | export interface Provider {
4 | id: string;
5 | description: string;
6 | good_free_tier: boolean;
7 | has_free_tier: boolean;
8 | href: string;
9 | icon: string;
10 | is_serverless: boolean;
11 | name: string;
12 | pricing_href: string;
13 | services_offered: ServiceOffered[];
14 | categories: Category[];
15 | }
16 | export interface ServiceOffered {
17 | category_name: Category["id"];
18 | name: string;
19 | description?: string;
20 | service_pricing_url?: string;
21 | pricing: {
22 | free_tier: {
23 | included: string;
24 | price_per_gb?: string;
25 | type: string;
26 | }[];
27 | plans: {
28 | name: string;
29 | plan_features: {
30 | name: string;
31 | values: {
32 | key: string;
33 | value: string;
34 | }[];
35 | }[];
36 | }[];
37 | };
38 | disabled: boolean;
39 | supported_types: string[];
40 | }
41 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "target": "ES2017",
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------