├── .node-version ├── .dockerignore ├── .env.example ├── public └── favicon.ico ├── .gitignore ├── postcss.config.js ├── .prettierrc ├── .vscode └── settings.json ├── app ├── server │ ├── env.ts │ ├── trpc │ │ ├── utils.ts │ │ ├── context.ts │ │ └── index.ts │ ├── db │ │ ├── index.ts │ │ └── schema.ts │ └── invariant.ts ├── lib │ ├── utils.ts │ └── trpc.ts ├── routes │ ├── api.trpc.$trpc.ts │ └── _index.tsx ├── entry.client.tsx ├── components │ └── ui │ │ ├── input.tsx │ │ ├── button.tsx │ │ └── dialog.tsx ├── root.tsx ├── css │ └── tailwind.css └── entry.server.tsx ├── .editorconfig ├── drizzle.config.ts ├── components.json ├── vite.config.ts ├── README.md ├── tsconfig.json ├── Dockerfile ├── package.json └── tailwind.config.js /.node-version: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/node_modules/ 3 | build/ 4 | .envrc 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_URL=postgresql://postgres:password@localhost:5432/remix-starter 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egoist/remix-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | .envrc 7 | *.log 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "plugins": ["prettier-plugin-tailwindcss"], 4 | "tailwindFunctions": ["tv"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tailwindCSS.experimental.classRegex": [ 3 | ["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"] 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /app/server/env.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | 3 | export const env = z 4 | .object({ 5 | POSTGRES_URL: z.string(), 6 | }) 7 | .parse(process.env) 8 | -------------------------------------------------------------------------------- /app/server/trpc/utils.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from "@trpc/server" 2 | import { Context } from "./context" 3 | 4 | export const t = initTRPC.context().create() 5 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/routes/api.trpc.$trpc.ts: -------------------------------------------------------------------------------- 1 | import { LoaderFunctionArgs } from "@remix-run/node" 2 | import { handler } from "~/server/trpc" 3 | 4 | export const loader = ({ request }: LoaderFunctionArgs) => handler(request) 5 | 6 | export const action = loader 7 | -------------------------------------------------------------------------------- /app/server/trpc/context.ts: -------------------------------------------------------------------------------- 1 | export function createContext({ 2 | req, 3 | resHeaders, 4 | }: { 5 | req: Request 6 | resHeaders: Headers 7 | }) { 8 | return { req, resHeaders } 9 | } 10 | 11 | export type Context = Awaited> 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /app/server/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/node-postgres" 2 | import pg from "pg" 3 | import { env } from "../env" 4 | import * as schema from "./schema" 5 | 6 | const client = new pg.Pool({ 7 | connectionString: env.POSTGRES_URL, 8 | }) 9 | 10 | export const db = drizzle(client, { schema }) 11 | 12 | export { schema } 13 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit" 2 | import invariant from "~/server/invariant" 3 | 4 | invariant(process.env.POSTGRES_URL, "POSTGRES_URL is required") 5 | 6 | export default defineConfig({ 7 | dialect: "postgresql", 8 | dbCredentials: { 9 | url: process.env.POSTGRES_URL, 10 | }, 11 | out: "./migrations", 12 | schema: "app/server/db/schema.ts", 13 | }) 14 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/css/tailwind.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /app/server/invariant.ts: -------------------------------------------------------------------------------- 1 | export class InvariantError extends Error {} 2 | 3 | export default function invariant( 4 | condition: any, 5 | message?: string | (() => string), 6 | ): asserts condition { 7 | if (condition) { 8 | return 9 | } 10 | 11 | const provided = typeof message === "function" ? message() : message 12 | const value = provided ?? "Invariant failed" 13 | 14 | throw new InvariantError(value) 15 | } 16 | -------------------------------------------------------------------------------- /app/server/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, timestamp } from "drizzle-orm/pg-core" 2 | import { ulid } from "ulidx" 3 | 4 | const defaultRandom = () => ulid().toLowerCase() 5 | const defaultNow = () => new Date() 6 | 7 | export const user = pgTable("user", { 8 | id: text("id").primaryKey().$defaultFn(defaultRandom), 9 | createdAt: timestamp("createdAt", { precision: 3 }) 10 | .$defaultFn(defaultNow) 11 | .notNull(), 12 | email: text("email").notNull().notNull(), 13 | }) 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev" 2 | import { defineConfig } from "vite" 3 | import tsconfigPaths from "vite-tsconfig-paths" 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | remix({ 8 | future: { 9 | v3_fetcherPersist: true, 10 | v3_relativeSplatPath: true, 11 | v3_throwAbortReason: true, 12 | unstable_singleFetch: true, 13 | }, 14 | }), 15 | tsconfigPaths(), 16 | ], 17 | server: { 18 | port: 3000, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react" 8 | import { startTransition, StrictMode } from "react" 9 | import { hydrateRoot } from "react-dom/client" 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | , 17 | ) 18 | }) 19 | -------------------------------------------------------------------------------- /app/server/trpc/index.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch" 2 | import { createContext } from "./context" 3 | import { t } from "./utils" 4 | 5 | export const appRouter = t.router({ 6 | hello: t.procedure.query(() => "world"), 7 | }) 8 | 9 | export type AppRouter = typeof appRouter 10 | 11 | export const handler = (request: Request, endpoint = "/api/trpc") => 12 | fetchRequestHandler({ 13 | endpoint, 14 | req: request, 15 | router: appRouter, 16 | createContext, 17 | }) 18 | 19 | const createCaller = t.createCallerFactory(appRouter) 20 | export const createTrpcServer = (req: Request, resHeaders = new Headers()) => 21 | createCaller(() => createContext({ req, resHeaders })) 22 | -------------------------------------------------------------------------------- /app/lib/trpc.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query" 2 | import { createTRPCReact, httpLink } from "@trpc/react-query" 3 | import type { AppRouter } from "~/server/trpc" 4 | 5 | export const trpc = createTRPCReact() 6 | 7 | export const createQueryClient = () => { 8 | return new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | // Consider data to be stale after this time 12 | // Only stale data will be refetched 13 | staleTime: 1000 * 3, // 3 seconds 14 | }, 15 | }, 16 | }) 17 | } 18 | 19 | const baseUrl = "/api/trpc" 20 | 21 | export const createTrpcClient = () => { 22 | return trpc.createClient({ 23 | links: [ 24 | httpLink({ 25 | url: baseUrl, 26 | }), 27 | ], 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Starter 2 | 3 | ## Features 4 | 5 | - [TRPC](https://trpc.io) 6 | - TailwindCSS and shadcn-ui 7 | - [Iconify Icons](https://github.com/egoist/tailwindcss-icons) 8 | - Postgres and [Drizzle-ORM](https://orm.drizzle.team/docs/overview) 9 | - Docker support (you can also use Vercel, Cloudflare Pages and others) 10 | 11 | ## Development 12 | 13 | Setup your environment variables: 14 | 15 | ```bash 16 | cp .env.example .env 17 | # adjust env variables in the .env file 18 | ``` 19 | 20 | Run the dev server: 21 | 22 | ```bash 23 | pnpm dev 24 | ``` 25 | 26 | ## Deployment 27 | 28 | First, build your app for production: 29 | 30 | ```bash 31 | pnpm build 32 | ``` 33 | 34 | Then run the app in production mode: 35 | 36 | ```bash 37 | pnpm start 38 | ``` 39 | 40 | Now you'll need to pick a host to deploy it to. 41 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": [ 13 | "@remix-run/node", 14 | "vite/client", 15 | "@remix-run/react/future/single-fetch.d.ts" 16 | ], 17 | "isolatedModules": true, 18 | "esModuleInterop": true, 19 | "jsx": "react-jsx", 20 | "module": "ESNext", 21 | "moduleResolution": "Bundler", 22 | "resolveJsonModule": true, 23 | "target": "ES2022", 24 | "strict": true, 25 | "allowJs": true, 26 | "skipLibCheck": true, 27 | "forceConsistentCasingInFileNames": true, 28 | "baseUrl": ".", 29 | "paths": { 30 | "~/*": ["./app/*"] 31 | }, 32 | 33 | // Vite takes care of building everything, not tsc. 34 | "noEmit": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node" 2 | import { useLoaderData } from "@remix-run/react" 3 | import { trpc } from "~/lib/trpc" 4 | import { createTrpcServer } from "~/server/trpc" 5 | 6 | export const meta: MetaFunction = () => { 7 | return [ 8 | { title: "New Remix App" }, 9 | { name: "description", content: "Welcome to Remix!" }, 10 | ] 11 | } 12 | 13 | // For demo purpose 14 | // Fetch data in the server loader 15 | // and pass fetched data to the client query as initialData 16 | // Remove it if you don't need to fetch data on the server 17 | export const loader = async (ctx: LoaderFunctionArgs) => { 18 | const trpcServer = createTrpcServer(ctx.request) 19 | const message = await trpcServer.hello() 20 | return { 21 | message, 22 | } 23 | } 24 | 25 | export default function Index() { 26 | const data = useLoaderData() 27 | const messageQuery = trpc.hello.useQuery(undefined, { 28 | initialData: data.message, 29 | }) 30 | 31 | return ( 32 |
33 |
Hello {messageQuery.data}
34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query" 2 | import "./css/tailwind.css" 3 | import { 4 | Links, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "@remix-run/react" 10 | import { useState } from "react" 11 | import { createQueryClient, createTrpcClient, trpc } from "./lib/trpc" 12 | 13 | export function Layout({ children }: { children: React.ReactNode }) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | export default function App() { 32 | const [queryClient] = useState(() => createQueryClient()) 33 | const [trpcClient] = useState(() => createTrpcClient()) 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust NODE_VERSION as desired 4 | ARG NODE_VERSION=20 5 | FROM node:${NODE_VERSION}-slim AS base 6 | 7 | RUN apt-get update -qq && \ 8 | apt-get install --no-install-recommends -y ca-certificates curl 9 | 10 | # LABEL fly_launch_runtime="Node.js" 11 | 12 | # Node.js app lives here 13 | WORKDIR /app 14 | 15 | # Enable corepack 16 | RUN corepack enable 17 | RUN pnpm --version 18 | 19 | # Throw-away build stage to reduce size of final image 20 | FROM base AS build 21 | 22 | # Install packages needed to build node modules 23 | RUN apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 24 | 25 | # Install node modules 26 | COPY . . 27 | RUN pnpm i 28 | RUN pnpm build 29 | 30 | # Remove dev dependencies 31 | # for monorepo: RUN rm -rf node_modules 32 | # for monorepo: RUN pnpm install --prod 33 | RUN pnpm prune --prod 34 | 35 | 36 | # Final stage for app image 37 | FROM base AS prod 38 | 39 | # Copy built application 40 | COPY --from=build /app /app 41 | 42 | # Set production environment 43 | ENV NODE_ENV="production" 44 | ENV PORT=3000 45 | 46 | # Start the server by default, this can be overwritten at runtime 47 | EXPOSE 3000 48 | 49 | HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 CMD curl --fail http://localhost:3000 || exit 1 50 | CMD [ "pnpm", "start" ] -------------------------------------------------------------------------------- /app/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --primary: 240 5.9% 10%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | 22 | --muted: 240 4.8% 95.9%; 23 | --muted-foreground: 240 3.8% 46.1%; 24 | 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 240 5.9% 90%; 32 | --input: 240 5.9% 90%; 33 | --ring: 240 10% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 240 10% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 240 10% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 240 10% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 240 5.9% 10%; 50 | 51 | --secondary: 240 3.7% 15.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 240 3.7% 15.9%; 55 | --muted-foreground: 240 5% 64.9%; 56 | 57 | --accent: 240 3.7% 15.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 240 3.7% 15.9%; 64 | --input: 240 3.7% 15.9%; 65 | --ring: 240 4.9% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-starter", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "remix vite:dev", 9 | "start": "remix-serve ./build/server/index.js", 10 | "typecheck": "tsc", 11 | "db-push": "drizzle-kit push", 12 | "db-migrate": "drizzle-kit migrate", 13 | "db-gen": "drizzle-kit generate" 14 | }, 15 | "dependencies": { 16 | "@radix-ui/react-dialog": "^1.1.2", 17 | "@radix-ui/react-icons": "^1.3.0", 18 | "@radix-ui/react-slot": "^1.1.0", 19 | "@remix-run/node": "^2.12.1", 20 | "@remix-run/react": "^2.12.1", 21 | "@remix-run/serve": "^2.12.1", 22 | "@tanstack/react-query": "^5.59.0", 23 | "@trpc/client": "11.0.0-rc.449", 24 | "@trpc/react-query": "11.0.0-rc.449", 25 | "@trpc/server": "11.0.0-rc.449", 26 | "class-variance-authority": "^0.7.0", 27 | "clsx": "^2.1.1", 28 | "drizzle-orm": "^0.33.0", 29 | "isbot": "^5.1.17", 30 | "pg": "^8.13.0", 31 | "react": "^18.3.1", 32 | "react-dom": "^18.3.1", 33 | "tailwind-merge": "^2.5.2", 34 | "tailwind-variants": "^0.2.1", 35 | "tailwindcss-animate": "^1.0.7", 36 | "ulidx": "^2.4.1", 37 | "zod": "^3.23.8" 38 | }, 39 | "devDependencies": { 40 | "@egoist/tailwindcss-icons": "^1.8.1", 41 | "@iconify-json/mingcute": "^1.2.0", 42 | "@iconify-json/tabler": "^1.2.4", 43 | "@remix-run/dev": "^2.12.1", 44 | "@types/pg": "^8.11.10", 45 | "@types/react": "^18.3.11", 46 | "@types/react-dom": "^18.3.0", 47 | "autoprefixer": "^10.4.20", 48 | "drizzle-kit": "^0.24.2", 49 | "postcss": "^8.4.38", 50 | "prettier": "^3.3.3", 51 | "prettier-plugin-tailwindcss": "^0.6.8", 52 | "tailwindcss": "^3.4.4", 53 | "typescript": "^5.6.2", 54 | "vite": "^5.4.8", 55 | "vite-tsconfig-paths": "^5.0.1" 56 | }, 57 | "engines": { 58 | "node": ">=20.0.0" 59 | }, 60 | "packageManager": "pnpm@9.10.0" 61 | } 62 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { tv, type VariantProps } from "tailwind-variants" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const buttonVariants = tv({ 8 | base: "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 14 | outline: 15 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "text-primary underline-offset-4 hover:underline", 20 | }, 21 | size: { 22 | default: "h-9 px-4 py-2", 23 | sm: "h-8 rounded-md px-3 text-xs", 24 | lg: "h-10 rounded-md px-8", 25 | icon: "h-9 w-9", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default", 31 | }, 32 | }) 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps { 37 | asChild?: boolean 38 | } 39 | 40 | const Button = React.forwardRef( 41 | ({ className, variant, size, asChild = false, ...props }, ref) => { 42 | const Comp = asChild ? Slot : "button" 43 | return ( 44 | 49 | ) 50 | }, 51 | ) 52 | Button.displayName = "Button" 53 | 54 | export { Button, buttonVariants } 55 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import animate from "tailwindcss-animate" 3 | import { iconsPlugin, getIconCollections } from "@egoist/tailwindcss-icons" 4 | import fs from "fs" 5 | import path from "path" 6 | 7 | const pkg = JSON.parse( 8 | fs.readFileSync(path.join(__dirname, "./package.json"), "utf-8"), 9 | ) 10 | /** @type {any[]} */ 11 | const collectionNames = Object.keys(pkg.devDependencies) 12 | .filter((name) => name.startsWith("@iconify-json/")) 13 | .map((name) => name.replace("@iconify-json/", "")) 14 | 15 | /** @type {import('tailwindcss').Config} */ 16 | export default { 17 | darkMode: ["class"], 18 | content: [ 19 | "./pages/**/*.{ts,tsx}", 20 | "./components/**/*.{ts,tsx}", 21 | "./app/**/*.{ts,tsx}", 22 | "./src/**/*.{ts,tsx}", 23 | ], 24 | prefix: "", 25 | theme: { 26 | container: { 27 | center: true, 28 | padding: "2rem", 29 | screens: { 30 | "2xl": "1400px", 31 | }, 32 | }, 33 | extend: { 34 | colors: { 35 | border: "hsl(var(--border))", 36 | input: "hsl(var(--input))", 37 | ring: "hsl(var(--ring))", 38 | background: "hsl(var(--background))", 39 | foreground: "hsl(var(--foreground))", 40 | primary: { 41 | DEFAULT: "hsl(var(--primary))", 42 | foreground: "hsl(var(--primary-foreground))", 43 | }, 44 | secondary: { 45 | DEFAULT: "hsl(var(--secondary))", 46 | foreground: "hsl(var(--secondary-foreground))", 47 | }, 48 | destructive: { 49 | DEFAULT: "hsl(var(--destructive))", 50 | foreground: "hsl(var(--destructive-foreground))", 51 | }, 52 | muted: { 53 | DEFAULT: "hsl(var(--muted))", 54 | foreground: "hsl(var(--muted-foreground))", 55 | }, 56 | accent: { 57 | DEFAULT: "hsl(var(--accent))", 58 | foreground: "hsl(var(--accent-foreground))", 59 | }, 60 | popover: { 61 | DEFAULT: "hsl(var(--popover))", 62 | foreground: "hsl(var(--popover-foreground))", 63 | }, 64 | card: { 65 | DEFAULT: "hsl(var(--card))", 66 | foreground: "hsl(var(--card-foreground))", 67 | }, 68 | }, 69 | borderRadius: { 70 | lg: "var(--radius)", 71 | md: "calc(var(--radius) - 2px)", 72 | sm: "calc(var(--radius) - 4px)", 73 | }, 74 | keyframes: { 75 | "accordion-down": { 76 | from: { height: "0" }, 77 | to: { height: "var(--radix-accordion-content-height)" }, 78 | }, 79 | "accordion-up": { 80 | from: { height: "var(--radix-accordion-content-height)" }, 81 | to: { height: "0" }, 82 | }, 83 | }, 84 | animation: { 85 | "accordion-down": "accordion-down 0.2s ease-out", 86 | "accordion-up": "accordion-up 0.2s ease-out", 87 | }, 88 | }, 89 | }, 90 | plugins: [ 91 | animate, 92 | iconsPlugin({ 93 | collections: getIconCollections(collectionNames), 94 | }), 95 | ], 96 | } 97 | -------------------------------------------------------------------------------- /app/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { Cross2Icon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream" 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node" 10 | import { createReadableStreamFromReadable } from "@remix-run/node" 11 | import { RemixServer } from "@remix-run/react" 12 | import { isbot } from "isbot" 13 | import { renderToPipeableStream } from "react-dom/server" 14 | 15 | const ABORT_DELAY = 5_000 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext, 26 | ) { 27 | return isbot(request.headers.get("user-agent") || "") 28 | ? handleBotRequest( 29 | request, 30 | responseStatusCode, 31 | responseHeaders, 32 | remixContext, 33 | ) 34 | : handleBrowserRequest( 35 | request, 36 | responseStatusCode, 37 | responseHeaders, 38 | remixContext, 39 | ) 40 | } 41 | 42 | function handleBotRequest( 43 | request: Request, 44 | responseStatusCode: number, 45 | responseHeaders: Headers, 46 | remixContext: EntryContext, 47 | ) { 48 | return new Promise((resolve, reject) => { 49 | let shellRendered = false 50 | const { pipe, abort } = renderToPipeableStream( 51 | , 56 | { 57 | onAllReady() { 58 | shellRendered = true 59 | const body = new PassThrough() 60 | const stream = createReadableStreamFromReadable(body) 61 | 62 | responseHeaders.set("Content-Type", "text/html") 63 | 64 | resolve( 65 | new Response(stream, { 66 | headers: responseHeaders, 67 | status: responseStatusCode, 68 | }), 69 | ) 70 | 71 | pipe(body) 72 | }, 73 | onShellError(error: unknown) { 74 | reject(error) 75 | }, 76 | onError(error: unknown) { 77 | responseStatusCode = 500 78 | // Log streaming rendering errors from inside the shell. Don't log 79 | // errors encountered during initial shell rendering since they'll 80 | // reject and get logged in handleDocumentRequest. 81 | if (shellRendered) { 82 | console.error(error) 83 | } 84 | }, 85 | }, 86 | ) 87 | 88 | setTimeout(abort, ABORT_DELAY) 89 | }) 90 | } 91 | 92 | function handleBrowserRequest( 93 | request: Request, 94 | responseStatusCode: number, 95 | responseHeaders: Headers, 96 | remixContext: EntryContext, 97 | ) { 98 | return new Promise((resolve, reject) => { 99 | let shellRendered = false 100 | const { pipe, abort } = renderToPipeableStream( 101 | , 106 | { 107 | onShellReady() { 108 | shellRendered = true 109 | const body = new PassThrough() 110 | const stream = createReadableStreamFromReadable(body) 111 | 112 | responseHeaders.set("Content-Type", "text/html") 113 | 114 | resolve( 115 | new Response(stream, { 116 | headers: responseHeaders, 117 | status: responseStatusCode, 118 | }), 119 | ) 120 | 121 | pipe(body) 122 | }, 123 | onShellError(error: unknown) { 124 | reject(error) 125 | }, 126 | onError(error: unknown) { 127 | responseStatusCode = 500 128 | // Log streaming rendering errors from inside the shell. Don't log 129 | // errors encountered during initial shell rendering since they'll 130 | // reject and get logged in handleDocumentRequest. 131 | if (shellRendered) { 132 | console.error(error) 133 | } 134 | }, 135 | }, 136 | ) 137 | 138 | setTimeout(abort, ABORT_DELAY) 139 | }) 140 | } 141 | --------------------------------------------------------------------------------