├── .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 | ![Home](./media/home.png) 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 | 7 | 8 | 10 | 16 | 17 | 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 | test 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 |
52 | 53 |
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 | 13 | 14 | 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 |
    48 | {first6FreeProviders.map((provider) => ( 49 | 55 | {provider.description} 56 | 57 | ))} 58 |
    59 | 63 | See all free providers ({providers.length}) 64 | 65 |
    66 |
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 | {`${title} 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 | Deploys.top logo 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 | {`${provider.name} 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 | {provider.name} 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 | {`${provider.name} 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 | {`${provider.name} 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 | {`${provider.name} 202 |
    203 |
    204 |

    205 | {provider.name} 206 |

    207 | 208 | {provider.description} 209 | 210 |
    211 |
    212 | )) 213 | )} 214 |
    215 | )} 216 |
    217 |
    218 |
    219 | Deploys.top logo 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 |
    162 |
    171 |
    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 | 38 | 39 | {}} 41 | className="[&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:w-5 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group]]:px-2 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground" 42 | > 43 | {children} 44 | 45 | 46 | 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 |
    61 | 68 |
    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 | 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 | --------------------------------------------------------------------------------