├── .dockerignore ├── .gitignore ├── public └── favicon.ico ├── start_with_migrations.sh ├── remix.env.d.ts ├── app ├── entry.client.tsx ├── routes │ ├── profile.tsx │ ├── index.tsx │ ├── logout.tsx │ ├── api │ │ └── on_item_insert.ts │ ├── profile │ │ └── index.tsx │ └── login.tsx ├── entry.server.tsx ├── root.tsx ├── utils │ ├── session.server.ts │ └── hasura.server.ts └── styles │ └── tailwind.css ├── tailwind.config.js ├── remix.config.js ├── tsconfig.json ├── fly.toml ├── README.md ├── package.json └── Dockerfile /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyoung697/remix-hasura-fly/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /start_with_migrations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | npx prisma migrate deploy 5 | npm run start 6 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./app/**/*.{ts,tsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [require("@tailwindcss/forms")], 7 | }; 8 | -------------------------------------------------------------------------------- /app/routes/profile.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "remix"; 2 | 3 | export default function ProfileRoute() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | assetsBuildDirectory: "public/build", 7 | publicPath: "/build/", 8 | serverBuildDirectory: "build", 9 | devServerPort: 8002, 10 | ignoredRouteFiles: [".*"] 11 | }; 12 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "remix"; 2 | 3 | export default function Index() { 4 | return ( 5 |
6 |

Welcome to Remix with Hasura

7 | 8 | Profile 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "remix"; 2 | import { redirect } from "remix"; 3 | import { logout } from "~/utils/session.server"; 4 | 5 | export let action: ActionFunction = async ({ request }) => { 6 | return logout(request); 7 | }; 8 | 9 | export let loader: LoaderFunction = async () => { 10 | return redirect("/"); 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "~/*": ["./app/*"] 15 | }, 16 | 17 | // Remix takes care of building everything in `remix build`. 18 | "noEmit": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import { RemixServer } from "remix"; 3 | import type { EntryContext } from "remix"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | const markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for red-sun-3656 on 2021-12-29T10:01:25-07:00 2 | 3 | app = "red-sun-3656" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [env] 10 | PORT = "8080" 11 | 12 | [experimental] 13 | allowed_public_ports = [] 14 | auto_rollback = true 15 | 16 | [[services]] 17 | http_checks = [] 18 | internal_port = 8080 19 | processes = ["app"] 20 | protocol = "tcp" 21 | script_checks = [] 22 | 23 | [services.concurrency] 24 | hard_limit = 25 25 | soft_limit = 20 26 | type = "connections" 27 | 28 | [[services.ports]] 29 | handlers = ["http"] 30 | port = 80 31 | 32 | [[services.ports]] 33 | handlers = ["tls", "http"] 34 | port = 443 35 | 36 | [[services.tcp_checks]] 37 | grace_period = "1s" 38 | interval = "15s" 39 | restart_limit = 0 40 | timeout = "2s" 41 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | LiveReload, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "remix"; 9 | import type { MetaFunction, LinksFunction } from "remix"; 10 | import styles from "~/styles/tailwind.css"; 11 | 12 | export const meta: MetaFunction = () => { 13 | return { title: "New Remix App" }; 14 | }; 15 | 16 | export let links: LinksFunction = () => { 17 | return [{ rel: "stylesheet", href: styles }]; 18 | }; 19 | 20 | export default function App() { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {process.env.NODE_ENV === "development" && } 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | 40 | ### Using a Template 41 | 42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. 43 | 44 | ```sh 45 | cd .. 46 | # create a new project, and pick a pre-configured host 47 | npx create-remix@latest 48 | cd my-new-remix-app 49 | # remove the new project's app (not the old one!) 50 | rm -rf app 51 | # copy your app over 52 | cp -R ../my-old-remix-app/app app 53 | ``` 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "remix-app-template", 4 | "description": "", 5 | "license": "", 6 | "scripts": { 7 | "build": "npm run build:css && remix build", 8 | "build:css": "tailwindcss -o ./app/styles/tailwind.css", 9 | "dev": "concurrently \"npm run dev:css\" \"node -r dotenv/config node_modules/.bin/remix dev\"", 10 | "dev:css": "tailwindcss -o ./app/styles/tailwind.css --watch", 11 | "postinstall": "remix setup node", 12 | "deploy": "fly deploy --remote-only", 13 | "start": "remix-serve build" 14 | }, 15 | "dependencies": { 16 | "@headlessui/react": "^1.4.2", 17 | "@heroicons/react": "^1.0.5", 18 | "@remix-run/react": "^1.1.1", 19 | "@remix-run/serve": "^1.1.1", 20 | "@tailwindcss/forms": "^0.4.0", 21 | "bcrypt": "^5.0.1", 22 | "dotenv": "^10.0.0", 23 | "graphql": "^16.2.0", 24 | "graphql-request": "^3.7.0", 25 | "react": "^17.0.2", 26 | "react-dom": "^17.0.2", 27 | "remix": "^1.1.1" 28 | }, 29 | "devDependencies": { 30 | "@remix-run/dev": "^1.1.1", 31 | "@types/bcrypt": "^5.0.0", 32 | "@types/react": "^17.0.24", 33 | "@types/react-dom": "^17.0.9", 34 | "concurrently": "^6.5.1", 35 | "tailwindcss": "^3.0.8", 36 | "typescript": "^4.1.2" 37 | }, 38 | "engines": { 39 | "node": ">=14" 40 | }, 41 | "sideEffects": false 42 | } 43 | -------------------------------------------------------------------------------- /app/routes/api/on_item_insert.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "remix"; 2 | import { hasuraAdminClient } from "~/utils/hasura.server"; 3 | import { gql } from "graphql-request"; 4 | 5 | const INSERT_ITEM_LOG = gql` 6 | mutation InsertItemLog($object: item_insert_log_insert_input!) { 7 | insert_item_insert_log_one(object: $object) { 8 | id 9 | } 10 | } 11 | `; 12 | 13 | export const action: ActionFunction = async ({ request }) => { 14 | if (!validateRequest(request)) { 15 | return new Response(null, { status: 401 }); 16 | } 17 | 18 | try { 19 | if (request.method === "POST") { 20 | /* handle "POST" */ 21 | const requestBody = await request.json(); 22 | const itemData = requestBody.event?.data?.new; 23 | 24 | if (!itemData) { 25 | return new Response("Invalid request", { status: 500 }); 26 | } 27 | 28 | hasuraAdminClient.request(INSERT_ITEM_LOG, { 29 | object: { item_json: itemData }, 30 | }); 31 | 32 | return new Response(null, { status: 200 }); 33 | } 34 | 35 | return new Response("Request method not supported", { status: 400 }); 36 | } catch (err) { 37 | console.log("err: ", err); 38 | return new Response(JSON.stringify(err), { status: 500 }); 39 | } 40 | }; 41 | 42 | function validateRequest(request: Request) { 43 | return request.headers.get("api-secret") === process.env.API_SECRET; 44 | } 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:16-bullseye-slim as base 3 | 4 | # Install openssl for Prisma 5 | RUN apt-get update && apt-get install -y openssl 6 | 7 | # Install all node_modules, including dev dependencies 8 | FROM base as deps 9 | 10 | RUN mkdir /app 11 | WORKDIR /app 12 | 13 | ADD package.json package-lock.json ./ 14 | RUN npm install --production=false 15 | 16 | # Setup production node_modules 17 | FROM base as production-deps 18 | 19 | RUN mkdir /app 20 | WORKDIR /app 21 | 22 | COPY --from=deps /app/node_modules /app/node_modules 23 | ADD package.json package-lock.json ./ 24 | RUN npm prune --production 25 | 26 | # Build the app 27 | FROM base as build 28 | 29 | ENV NODE_ENV=production 30 | 31 | RUN mkdir /app 32 | WORKDIR /app 33 | 34 | COPY --from=deps /app/node_modules /app/node_modules 35 | 36 | # If we're using Prisma, uncomment to cache the prisma schema 37 | # ADD prisma . 38 | # RUN npx prisma generate 39 | 40 | ADD . . 41 | RUN npm run build 42 | 43 | # Finally, build the production image with minimal footprint 44 | FROM base 45 | 46 | ENV NODE_ENV=production 47 | 48 | RUN mkdir /app 49 | WORKDIR /app 50 | 51 | COPY --from=production-deps /app/node_modules /app/node_modules 52 | 53 | # Uncomment if using Prisma 54 | # COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma 55 | 56 | COPY --from=build /app/build /app/build 57 | COPY --from=build /app/public /app/public 58 | ADD . . 59 | 60 | CMD ["npm", "run", "start"] 61 | -------------------------------------------------------------------------------- /app/utils/session.server.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { createCookieSessionStorage, redirect } from "remix"; 3 | import { getUserById, getUserByUsername, createUser } from "./hasura.server"; 4 | 5 | type LoginForm = { 6 | username: string; 7 | password: string; 8 | }; 9 | 10 | export async function register({ username, password }: LoginForm) { 11 | let passwordHash = await bcrypt.hash(password, 10); 12 | 13 | try { 14 | return await createUser(username, passwordHash); 15 | } catch (err) { 16 | return null; 17 | } 18 | } 19 | 20 | export async function login({ username, password }: LoginForm) { 21 | const user = await getUserByUsername(username); 22 | if (!user) return null; 23 | const isCorrectPassword = await bcrypt.compare(password, user.password_hash); 24 | if (!isCorrectPassword) return null; 25 | return user; 26 | } 27 | 28 | let sessionSecret = process.env.SESSION_SECRET; 29 | if (!sessionSecret) { 30 | throw new Error("SESSION_SECRET must be set"); 31 | } 32 | 33 | let { getSession, commitSession, destroySession } = createCookieSessionStorage({ 34 | cookie: { 35 | name: "RemixHasuraSession", 36 | secure: true, 37 | secrets: [sessionSecret], 38 | sameSite: "lax", 39 | path: "/", 40 | maxAge: 60 * 60 * 24 * 30, 41 | httpOnly: true, 42 | }, 43 | }); 44 | 45 | export function getUserSession(request: Request) { 46 | return getSession(request.headers.get("Cookie")); 47 | } 48 | 49 | export async function getUserId(request: Request) { 50 | let session = await getUserSession(request); 51 | let userId = session.get("userId"); 52 | if (!userId || typeof userId !== "string") throw redirect("/login"); 53 | return userId; 54 | } 55 | 56 | export async function getUser(request: Request) { 57 | let userId = await getUserId(request); 58 | if (typeof userId !== "string") return null; 59 | 60 | try { 61 | const user = await getUserById(userId); 62 | return user; 63 | } catch { 64 | throw logout(request); 65 | } 66 | } 67 | 68 | export async function logout(request: Request) { 69 | let session = await getSession(request.headers.get("Cookie")); 70 | return redirect("/login", { 71 | headers: { "Set-Cookie": await destroySession(session) }, 72 | }); 73 | } 74 | 75 | export async function createUserSession(userId: string, redirectTo: string) { 76 | let session = await getSession(); 77 | session.set("userId", userId); 78 | return redirect(redirectTo, { 79 | headers: { "Set-Cookie": await commitSession(session) }, 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /app/routes/profile/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData, Form } from "remix"; 2 | import type { LoaderFunction } from "remix"; 3 | import { gql } from "graphql-request"; 4 | import { getUser } from "~/utils/session.server"; 5 | import { User, getHasuraClient } from "~/utils/hasura.server"; 6 | 7 | const GET_USER_ITEMS = gql` 8 | query GetItems { 9 | item { 10 | id 11 | name 12 | } 13 | } 14 | `; 15 | 16 | type Item = { 17 | id: number; 18 | name: string; 19 | }; 20 | type LoaderData = { 21 | user: User; 22 | items: Array; 23 | }; 24 | 25 | export const loader: LoaderFunction = async ({ request }) => { 26 | const user = await getUser(request); 27 | 28 | if (user) { 29 | const { item } = await getHasuraClient(user.id).request(GET_USER_ITEMS); 30 | 31 | return { 32 | user, 33 | items: item, 34 | }; 35 | } 36 | }; 37 | 38 | export default function ProfileRoute() { 39 | const data = useLoaderData(); 40 | 41 | return ( 42 |
43 |
44 |

45 | Welcome,{" "} 46 | {data?.user?.username} 47 |

48 | 49 |
50 | Items 51 | 52 | {data.items.map((item) => ( 53 |
54 |
55 | 62 |
63 |
64 | 70 |
71 |
72 | ))} 73 |
74 | 75 |
76 | 82 |
83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /app/utils/hasura.server.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient, gql } from "graphql-request"; 2 | 3 | const GRAPHQL_ENDPOINT = process.env.GRAPHQL_ENDPOINT || ""; 4 | const GRAPHQL_ADMIN_SECRET = process.env.GRAPHQL_ADMIN_SECRET || ""; 5 | 6 | if (!GRAPHQL_ENDPOINT || !GRAPHQL_ADMIN_SECRET) { 7 | throw new Error("GRAPHQL_ENDPOINT & GRAPHQL_ADMIN_SECRET must be set"); 8 | } 9 | 10 | let hasuraClient: GraphQLClient | undefined; 11 | function getHasuraClient(userId: string) { 12 | if (!hasuraClient) { 13 | hasuraClient = new GraphQLClient(GRAPHQL_ENDPOINT, { 14 | headers: { 15 | "x-hasura-admin-secret": GRAPHQL_ADMIN_SECRET, 16 | "x-hasura-role": "user", 17 | }, 18 | }); 19 | } 20 | 21 | hasuraClient.setHeader("x-hasura-user-id", userId); 22 | 23 | return hasuraClient; 24 | } 25 | 26 | const hasuraAdminClient: GraphQLClient = new GraphQLClient(GRAPHQL_ENDPOINT, { 27 | headers: { 28 | "x-hasura-admin-secret": GRAPHQL_ADMIN_SECRET, 29 | }, 30 | }); 31 | 32 | type User = { 33 | id: string; 34 | username: string; 35 | password_hash: string; 36 | }; 37 | 38 | export async function getUserById(id: string): Promise { 39 | const { user_by_pk } = await hasuraAdminClient.request( 40 | gql` 41 | query GetUserByPk($id: uuid!) { 42 | user_by_pk(id: $id) { 43 | id 44 | username 45 | password_hash 46 | } 47 | } 48 | `, 49 | { id } 50 | ); 51 | 52 | if (!user_by_pk) { 53 | throw new Error("Invalid user id"); 54 | } 55 | 56 | return user_by_pk; 57 | } 58 | 59 | export async function getUserByUsername( 60 | username: string 61 | ): Promise { 62 | const { user } = await hasuraAdminClient.request( 63 | gql` 64 | query GetUserByUsername($username: String!) { 65 | user(where: { username: { _eq: $username } }) { 66 | id 67 | username 68 | password_hash 69 | } 70 | } 71 | `, 72 | { username } 73 | ); 74 | 75 | if (!user || !user.length) { 76 | return null; 77 | } 78 | 79 | return user[0]; 80 | } 81 | 82 | export async function createUser( 83 | username: string, 84 | password_hash: string 85 | ): Promise { 86 | const { insert_user_one } = await hasuraAdminClient.request( 87 | gql` 88 | mutation CreateUser($object: user_insert_input!) { 89 | insert_user_one(object: $object) { 90 | id 91 | username 92 | password_hash 93 | } 94 | } 95 | `, 96 | { 97 | object: { 98 | password_hash, 99 | username, 100 | }, 101 | } 102 | ); 103 | 104 | return insert_user_one; 105 | } 106 | 107 | export { getHasuraClient, hasuraAdminClient }; 108 | export type { User }; 109 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, HeadersFunction, MetaFunction } from "remix"; 2 | import { useActionData, Form } from "remix"; 3 | import { login, createUserSession, register } from "~/utils/session.server"; 4 | import { getUserByUsername } from "~/utils/hasura.server"; 5 | import { 6 | LockClosedIcon, 7 | XCircleIcon, 8 | ExclamationIcon, 9 | } from "@heroicons/react/solid"; 10 | 11 | export let meta: MetaFunction = () => { 12 | return { 13 | title: "Remix Hasura Fly | Login", 14 | description: "Login to authenticate with Hasura!", 15 | }; 16 | }; 17 | 18 | export let headers: HeadersFunction = () => { 19 | return { 20 | "Cache-Control": `public, max-age=${60 * 10}, s-maxage=${ 21 | 60 * 60 * 24 * 30 22 | }`, 23 | }; 24 | }; 25 | 26 | function validateUsername(username: unknown) { 27 | if (typeof username !== "string" || username.length < 3) { 28 | return `Usernames must be at least 3 characters long`; 29 | } 30 | } 31 | 32 | function validatePassword(password: unknown) { 33 | if (typeof password !== "string" || password.length < 6) { 34 | return `Passwords must be at least 6 characters long`; 35 | } 36 | } 37 | 38 | type ActionData = { 39 | formError?: string; 40 | fieldErrors?: { username: string | undefined; password: string | undefined }; 41 | fields?: { loginType: string; username: string; password: string }; 42 | }; 43 | 44 | export let action: ActionFunction = async ({ 45 | request, 46 | }): Promise => { 47 | let { loginType, username, password } = Object.fromEntries( 48 | await request.formData() 49 | ); 50 | if ( 51 | typeof loginType !== "string" || 52 | typeof username !== "string" || 53 | typeof password !== "string" 54 | ) { 55 | return { formError: `Form not submitted correctly.` }; 56 | } 57 | 58 | let fields = { loginType, username, password }; 59 | let fieldErrors = { 60 | username: validateUsername(username), 61 | password: validatePassword(password), 62 | }; 63 | if (Object.values(fieldErrors).some(Boolean)) return { fieldErrors, fields }; 64 | 65 | switch (loginType) { 66 | case "login": { 67 | const user = await login({ username, password }); 68 | if (!user) { 69 | return { 70 | fields, 71 | formError: `Username/Password combination is incorrect`, 72 | }; 73 | } 74 | return createUserSession(user.id, "/profile"); 75 | } 76 | case "register": { 77 | let userExists = await getUserByUsername(username); 78 | if (userExists) { 79 | return { 80 | fields, 81 | formError: `User with username ${username} already exists`, 82 | }; 83 | } 84 | const user = await register({ username, password }); 85 | if (!user) { 86 | return { 87 | fields, 88 | formError: `Something went wrong trying to create a new user.`, 89 | }; 90 | } 91 | return createUserSession(user.id, "/profile"); 92 | } 93 | default: { 94 | return { fields, formError: `Login type invalid` }; 95 | } 96 | } 97 | }; 98 | 99 | export default function Login() { 100 | const actionData = useActionData(); 101 | 102 | return ( 103 |
104 |
105 |
106 |

107 | Sign in to your account 108 |

109 |
110 |
117 |
118 | Login or Register? 119 |
120 |
121 | 131 | 134 |
135 |
136 | 143 | 146 |
147 |
148 |
149 | 150 |
151 |
152 | 155 | 169 |
170 |
171 | 174 | 188 |
189 |
190 | 191 | {actionData?.fieldErrors ? ( 192 |
193 |
194 |
195 |
200 |
201 |

202 | There are errors with your submission 203 |

204 |
205 |
    206 | {actionData.fieldErrors.username ? ( 207 |
  • {actionData.fieldErrors.username}
  • 208 | ) : null} 209 | {actionData.fieldErrors.password ? ( 210 |
  • {actionData.fieldErrors.password}
  • 211 | ) : null} 212 |
213 |
214 |
215 |
216 |
217 | ) : null} 218 | 219 | {actionData?.formError ? ( 220 |
221 |
222 |
223 |
228 |
229 |

230 | Login error 231 |

232 |
233 |

{actionData.formError}

234 |
235 |
236 |
237 |
238 | ) : null} 239 | 240 | 252 |
253 |
254 |
255 | ); 256 | } 257 | -------------------------------------------------------------------------------- /app/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.0.8 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: currentColor; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | */ 34 | 35 | html { 36 | line-height: 1.5; 37 | /* 1 */ 38 | -webkit-text-size-adjust: 100%; 39 | /* 2 */ 40 | -moz-tab-size: 4; 41 | /* 3 */ 42 | -o-tab-size: 4; 43 | tab-size: 4; 44 | /* 3 */ 45 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 46 | /* 4 */ 47 | } 48 | 49 | /* 50 | 1. Remove the margin in all browsers. 51 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 52 | */ 53 | 54 | body { 55 | margin: 0; 56 | /* 1 */ 57 | line-height: inherit; 58 | /* 2 */ 59 | } 60 | 61 | /* 62 | 1. Add the correct height in Firefox. 63 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 64 | 3. Ensure horizontal rules are visible by default. 65 | */ 66 | 67 | hr { 68 | height: 0; 69 | /* 1 */ 70 | color: inherit; 71 | /* 2 */ 72 | border-top-width: 1px; 73 | /* 3 */ 74 | } 75 | 76 | /* 77 | Add the correct text decoration in Chrome, Edge, and Safari. 78 | */ 79 | 80 | abbr:where([title]) { 81 | -webkit-text-decoration: underline dotted; 82 | text-decoration: underline dotted; 83 | } 84 | 85 | /* 86 | Remove the default font size and weight for headings. 87 | */ 88 | 89 | h1, 90 | h2, 91 | h3, 92 | h4, 93 | h5, 94 | h6 { 95 | font-size: inherit; 96 | font-weight: inherit; 97 | } 98 | 99 | /* 100 | Reset links to optimize for opt-in styling instead of opt-out. 101 | */ 102 | 103 | a { 104 | color: inherit; 105 | text-decoration: inherit; 106 | } 107 | 108 | /* 109 | Add the correct font weight in Edge and Safari. 110 | */ 111 | 112 | b, 113 | strong { 114 | font-weight: bolder; 115 | } 116 | 117 | /* 118 | 1. Use the user's configured `mono` font family by default. 119 | 2. Correct the odd `em` font sizing in all browsers. 120 | */ 121 | 122 | code, 123 | kbd, 124 | samp, 125 | pre { 126 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 127 | /* 1 */ 128 | font-size: 1em; 129 | /* 2 */ 130 | } 131 | 132 | /* 133 | Add the correct font size in all browsers. 134 | */ 135 | 136 | small { 137 | font-size: 80%; 138 | } 139 | 140 | /* 141 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 142 | */ 143 | 144 | sub, 145 | sup { 146 | font-size: 75%; 147 | line-height: 0; 148 | position: relative; 149 | vertical-align: baseline; 150 | } 151 | 152 | sub { 153 | bottom: -0.25em; 154 | } 155 | 156 | sup { 157 | top: -0.5em; 158 | } 159 | 160 | /* 161 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 162 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 163 | 3. Remove gaps between table borders by default. 164 | */ 165 | 166 | table { 167 | text-indent: 0; 168 | /* 1 */ 169 | border-color: inherit; 170 | /* 2 */ 171 | border-collapse: collapse; 172 | /* 3 */ 173 | } 174 | 175 | /* 176 | 1. Change the font styles in all browsers. 177 | 2. Remove the margin in Firefox and Safari. 178 | 3. Remove default padding in all browsers. 179 | */ 180 | 181 | button, 182 | input, 183 | optgroup, 184 | select, 185 | textarea { 186 | font-family: inherit; 187 | /* 1 */ 188 | font-size: 100%; 189 | /* 1 */ 190 | line-height: inherit; 191 | /* 1 */ 192 | color: inherit; 193 | /* 1 */ 194 | margin: 0; 195 | /* 2 */ 196 | padding: 0; 197 | /* 3 */ 198 | } 199 | 200 | /* 201 | Remove the inheritance of text transform in Edge and Firefox. 202 | */ 203 | 204 | button, 205 | select { 206 | text-transform: none; 207 | } 208 | 209 | /* 210 | 1. Correct the inability to style clickable types in iOS and Safari. 211 | 2. Remove default button styles. 212 | */ 213 | 214 | button, 215 | [type='button'], 216 | [type='reset'], 217 | [type='submit'] { 218 | -webkit-appearance: button; 219 | /* 1 */ 220 | background-color: transparent; 221 | /* 2 */ 222 | background-image: none; 223 | /* 2 */ 224 | } 225 | 226 | /* 227 | Use the modern Firefox focus style for all focusable elements. 228 | */ 229 | 230 | :-moz-focusring { 231 | outline: auto; 232 | } 233 | 234 | /* 235 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 236 | */ 237 | 238 | :-moz-ui-invalid { 239 | box-shadow: none; 240 | } 241 | 242 | /* 243 | Add the correct vertical alignment in Chrome and Firefox. 244 | */ 245 | 246 | progress { 247 | vertical-align: baseline; 248 | } 249 | 250 | /* 251 | Correct the cursor style of increment and decrement buttons in Safari. 252 | */ 253 | 254 | ::-webkit-inner-spin-button, 255 | ::-webkit-outer-spin-button { 256 | height: auto; 257 | } 258 | 259 | /* 260 | 1. Correct the odd appearance in Chrome and Safari. 261 | 2. Correct the outline style in Safari. 262 | */ 263 | 264 | [type='search'] { 265 | -webkit-appearance: textfield; 266 | /* 1 */ 267 | outline-offset: -2px; 268 | /* 2 */ 269 | } 270 | 271 | /* 272 | Remove the inner padding in Chrome and Safari on macOS. 273 | */ 274 | 275 | ::-webkit-search-decoration { 276 | -webkit-appearance: none; 277 | } 278 | 279 | /* 280 | 1. Correct the inability to style clickable types in iOS and Safari. 281 | 2. Change font properties to `inherit` in Safari. 282 | */ 283 | 284 | ::-webkit-file-upload-button { 285 | -webkit-appearance: button; 286 | /* 1 */ 287 | font: inherit; 288 | /* 2 */ 289 | } 290 | 291 | /* 292 | Add the correct display in Chrome and Safari. 293 | */ 294 | 295 | summary { 296 | display: list-item; 297 | } 298 | 299 | /* 300 | Removes the default spacing and border for appropriate elements. 301 | */ 302 | 303 | blockquote, 304 | dl, 305 | dd, 306 | h1, 307 | h2, 308 | h3, 309 | h4, 310 | h5, 311 | h6, 312 | hr, 313 | figure, 314 | p, 315 | pre { 316 | margin: 0; 317 | } 318 | 319 | fieldset { 320 | margin: 0; 321 | padding: 0; 322 | } 323 | 324 | legend { 325 | padding: 0; 326 | } 327 | 328 | ol, 329 | ul, 330 | menu { 331 | list-style: none; 332 | margin: 0; 333 | padding: 0; 334 | } 335 | 336 | /* 337 | Prevent resizing textareas horizontally by default. 338 | */ 339 | 340 | textarea { 341 | resize: vertical; 342 | } 343 | 344 | /* 345 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 346 | 2. Set the default placeholder color to the user's configured gray 400 color. 347 | */ 348 | 349 | input::-moz-placeholder, textarea::-moz-placeholder { 350 | opacity: 1; 351 | /* 1 */ 352 | color: #9ca3af; 353 | /* 2 */ 354 | } 355 | 356 | input:-ms-input-placeholder, textarea:-ms-input-placeholder { 357 | opacity: 1; 358 | /* 1 */ 359 | color: #9ca3af; 360 | /* 2 */ 361 | } 362 | 363 | input::placeholder, 364 | textarea::placeholder { 365 | opacity: 1; 366 | /* 1 */ 367 | color: #9ca3af; 368 | /* 2 */ 369 | } 370 | 371 | /* 372 | Set the default cursor for buttons. 373 | */ 374 | 375 | button, 376 | [role="button"] { 377 | cursor: pointer; 378 | } 379 | 380 | /* 381 | Make sure disabled buttons don't get the pointer cursor. 382 | */ 383 | 384 | :disabled { 385 | cursor: default; 386 | } 387 | 388 | /* 389 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 390 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 391 | This can trigger a poorly considered lint error in some tools but is included by design. 392 | */ 393 | 394 | img, 395 | svg, 396 | video, 397 | canvas, 398 | audio, 399 | iframe, 400 | embed, 401 | object { 402 | display: block; 403 | /* 1 */ 404 | vertical-align: middle; 405 | /* 2 */ 406 | } 407 | 408 | /* 409 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 410 | */ 411 | 412 | img, 413 | video { 414 | max-width: 100%; 415 | height: auto; 416 | } 417 | 418 | /* 419 | Ensure the default browser behavior of the `hidden` attribute. 420 | */ 421 | 422 | [hidden] { 423 | display: none; 424 | } 425 | 426 | [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { 427 | -webkit-appearance: none; 428 | -moz-appearance: none; 429 | appearance: none; 430 | background-color: #fff; 431 | border-color: #6b7280; 432 | border-width: 1px; 433 | border-radius: 0px; 434 | padding-top: 0.5rem; 435 | padding-right: 0.75rem; 436 | padding-bottom: 0.5rem; 437 | padding-left: 0.75rem; 438 | font-size: 1rem; 439 | line-height: 1.5rem; 440 | --tw-shadow: 0 0 #0000; 441 | } 442 | 443 | [type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { 444 | outline: 2px solid transparent; 445 | outline-offset: 2px; 446 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 447 | --tw-ring-offset-width: 0px; 448 | --tw-ring-offset-color: #fff; 449 | --tw-ring-color: #2563eb; 450 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 451 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 452 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 453 | border-color: #2563eb; 454 | } 455 | 456 | input::-moz-placeholder, textarea::-moz-placeholder { 457 | color: #6b7280; 458 | opacity: 1; 459 | } 460 | 461 | input:-ms-input-placeholder, textarea:-ms-input-placeholder { 462 | color: #6b7280; 463 | opacity: 1; 464 | } 465 | 466 | input::placeholder,textarea::placeholder { 467 | color: #6b7280; 468 | opacity: 1; 469 | } 470 | 471 | ::-webkit-datetime-edit-fields-wrapper { 472 | padding: 0; 473 | } 474 | 475 | ::-webkit-date-and-time-value { 476 | min-height: 1.5em; 477 | } 478 | 479 | select { 480 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 481 | background-position: right 0.5rem center; 482 | background-repeat: no-repeat; 483 | background-size: 1.5em 1.5em; 484 | padding-right: 2.5rem; 485 | -webkit-print-color-adjust: exact; 486 | color-adjust: exact; 487 | } 488 | 489 | [multiple] { 490 | background-image: initial; 491 | background-position: initial; 492 | background-repeat: unset; 493 | background-size: initial; 494 | padding-right: 0.75rem; 495 | -webkit-print-color-adjust: unset; 496 | color-adjust: unset; 497 | } 498 | 499 | [type='checkbox'],[type='radio'] { 500 | -webkit-appearance: none; 501 | -moz-appearance: none; 502 | appearance: none; 503 | padding: 0; 504 | -webkit-print-color-adjust: exact; 505 | color-adjust: exact; 506 | display: inline-block; 507 | vertical-align: middle; 508 | background-origin: border-box; 509 | -webkit-user-select: none; 510 | -moz-user-select: none; 511 | -ms-user-select: none; 512 | user-select: none; 513 | flex-shrink: 0; 514 | height: 1rem; 515 | width: 1rem; 516 | color: #2563eb; 517 | background-color: #fff; 518 | border-color: #6b7280; 519 | border-width: 1px; 520 | --tw-shadow: 0 0 #0000; 521 | } 522 | 523 | [type='checkbox'] { 524 | border-radius: 0px; 525 | } 526 | 527 | [type='radio'] { 528 | border-radius: 100%; 529 | } 530 | 531 | [type='checkbox']:focus,[type='radio']:focus { 532 | outline: 2px solid transparent; 533 | outline-offset: 2px; 534 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 535 | --tw-ring-offset-width: 2px; 536 | --tw-ring-offset-color: #fff; 537 | --tw-ring-color: #2563eb; 538 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 539 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 540 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 541 | } 542 | 543 | [type='checkbox']:checked,[type='radio']:checked { 544 | border-color: transparent; 545 | background-color: currentColor; 546 | background-size: 100% 100%; 547 | background-position: center; 548 | background-repeat: no-repeat; 549 | } 550 | 551 | [type='checkbox']:checked { 552 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 553 | } 554 | 555 | [type='radio']:checked { 556 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); 557 | } 558 | 559 | [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { 560 | border-color: transparent; 561 | background-color: currentColor; 562 | } 563 | 564 | [type='checkbox']:indeterminate { 565 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); 566 | border-color: transparent; 567 | background-color: currentColor; 568 | background-size: 100% 100%; 569 | background-position: center; 570 | background-repeat: no-repeat; 571 | } 572 | 573 | [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { 574 | border-color: transparent; 575 | background-color: currentColor; 576 | } 577 | 578 | [type='file'] { 579 | background: unset; 580 | border-color: inherit; 581 | border-width: 0; 582 | border-radius: 0; 583 | padding: 0; 584 | font-size: unset; 585 | line-height: inherit; 586 | } 587 | 588 | [type='file']:focus { 589 | outline: 1px auto -webkit-focus-ring-color; 590 | } 591 | 592 | *, ::before, ::after { 593 | --tw-border-opacity: 1; 594 | border-color: rgb(229 231 235 / var(--tw-border-opacity)); 595 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); 596 | --tw-ring-offset-width: 0px; 597 | --tw-ring-offset-color: #fff; 598 | --tw-ring-color: rgb(59 130 246 / 0.5); 599 | --tw-ring-offset-shadow: 0 0 #0000; 600 | --tw-ring-shadow: 0 0 #0000; 601 | --tw-shadow: 0 0 #0000; 602 | --tw-shadow-colored: 0 0 #0000; 603 | } 604 | 605 | .sr-only { 606 | position: absolute; 607 | width: 1px; 608 | height: 1px; 609 | padding: 0; 610 | margin: -1px; 611 | overflow: hidden; 612 | clip: rect(0, 0, 0, 0); 613 | white-space: nowrap; 614 | border-width: 0; 615 | } 616 | 617 | .absolute { 618 | position: absolute; 619 | } 620 | 621 | .relative { 622 | position: relative; 623 | } 624 | 625 | .inset-y-0 { 626 | top: 0px; 627 | bottom: 0px; 628 | } 629 | 630 | .left-0 { 631 | left: 0px; 632 | } 633 | 634 | .mx-auto { 635 | margin-left: auto; 636 | margin-right: auto; 637 | } 638 | 639 | .mt-6 { 640 | margin-top: 1.5rem; 641 | } 642 | 643 | .mt-8 { 644 | margin-top: 2rem; 645 | } 646 | 647 | .ml-3 { 648 | margin-left: 0.75rem; 649 | } 650 | 651 | .mt-2 { 652 | margin-top: 0.5rem; 653 | } 654 | 655 | .mt-20 { 656 | margin-top: 5rem; 657 | } 658 | 659 | .block { 660 | display: block; 661 | } 662 | 663 | .flex { 664 | display: flex; 665 | } 666 | 667 | .inline-flex { 668 | display: inline-flex; 669 | } 670 | 671 | .h-full { 672 | height: 100%; 673 | } 674 | 675 | .h-4 { 676 | height: 1rem; 677 | } 678 | 679 | .h-5 { 680 | height: 1.25rem; 681 | } 682 | 683 | .min-h-full { 684 | min-height: 100%; 685 | } 686 | 687 | .w-full { 688 | width: 100%; 689 | } 690 | 691 | .w-4 { 692 | width: 1rem; 693 | } 694 | 695 | .w-5 { 696 | width: 1.25rem; 697 | } 698 | 699 | .max-w-md { 700 | max-width: 28rem; 701 | } 702 | 703 | .max-w-4xl { 704 | max-width: 56rem; 705 | } 706 | 707 | .flex-shrink-0 { 708 | flex-shrink: 0; 709 | } 710 | 711 | .list-disc { 712 | list-style-type: disc; 713 | } 714 | 715 | .appearance-none { 716 | -webkit-appearance: none; 717 | -moz-appearance: none; 718 | appearance: none; 719 | } 720 | 721 | .items-start { 722 | align-items: flex-start; 723 | } 724 | 725 | .items-center { 726 | align-items: center; 727 | } 728 | 729 | .justify-center { 730 | justify-content: center; 731 | } 732 | 733 | .space-y-8 > :not([hidden]) ~ :not([hidden]) { 734 | --tw-space-y-reverse: 0; 735 | margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse))); 736 | margin-bottom: calc(2rem * var(--tw-space-y-reverse)); 737 | } 738 | 739 | .space-y-6 > :not([hidden]) ~ :not([hidden]) { 740 | --tw-space-y-reverse: 0; 741 | margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); 742 | margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); 743 | } 744 | 745 | .space-y-4 > :not([hidden]) ~ :not([hidden]) { 746 | --tw-space-y-reverse: 0; 747 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); 748 | margin-bottom: calc(1rem * var(--tw-space-y-reverse)); 749 | } 750 | 751 | .-space-y-px > :not([hidden]) ~ :not([hidden]) { 752 | --tw-space-y-reverse: 0; 753 | margin-top: calc(-1px * calc(1 - var(--tw-space-y-reverse))); 754 | margin-bottom: calc(-1px * var(--tw-space-y-reverse)); 755 | } 756 | 757 | .space-y-1 > :not([hidden]) ~ :not([hidden]) { 758 | --tw-space-y-reverse: 0; 759 | margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); 760 | margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); 761 | } 762 | 763 | .space-y-5 > :not([hidden]) ~ :not([hidden]) { 764 | --tw-space-y-reverse: 0; 765 | margin-top: calc(1.25rem * calc(1 - var(--tw-space-y-reverse))); 766 | margin-bottom: calc(1.25rem * var(--tw-space-y-reverse)); 767 | } 768 | 769 | .overflow-hidden { 770 | overflow: hidden; 771 | } 772 | 773 | .rounded-md { 774 | border-radius: 0.375rem; 775 | } 776 | 777 | .rounded-none { 778 | border-radius: 0px; 779 | } 780 | 781 | .rounded-lg { 782 | border-radius: 0.5rem; 783 | } 784 | 785 | .rounded { 786 | border-radius: 0.25rem; 787 | } 788 | 789 | .rounded-t-md { 790 | border-top-left-radius: 0.375rem; 791 | border-top-right-radius: 0.375rem; 792 | } 793 | 794 | .rounded-b-md { 795 | border-bottom-right-radius: 0.375rem; 796 | border-bottom-left-radius: 0.375rem; 797 | } 798 | 799 | .border { 800 | border-width: 1px; 801 | } 802 | 803 | .border-gray-300 { 804 | --tw-border-opacity: 1; 805 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 806 | } 807 | 808 | .border-transparent { 809 | border-color: transparent; 810 | } 811 | 812 | .bg-gray-50 { 813 | --tw-bg-opacity: 1; 814 | background-color: rgb(249 250 251 / var(--tw-bg-opacity)); 815 | } 816 | 817 | .bg-red-50 { 818 | --tw-bg-opacity: 1; 819 | background-color: rgb(254 242 242 / var(--tw-bg-opacity)); 820 | } 821 | 822 | .bg-yellow-50 { 823 | --tw-bg-opacity: 1; 824 | background-color: rgb(254 252 232 / var(--tw-bg-opacity)); 825 | } 826 | 827 | .bg-indigo-600 { 828 | --tw-bg-opacity: 1; 829 | background-color: rgb(79 70 229 / var(--tw-bg-opacity)); 830 | } 831 | 832 | .bg-white { 833 | --tw-bg-opacity: 1; 834 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 835 | } 836 | 837 | .p-4 { 838 | padding: 1rem; 839 | } 840 | 841 | .py-12 { 842 | padding-top: 3rem; 843 | padding-bottom: 3rem; 844 | } 845 | 846 | .px-4 { 847 | padding-left: 1rem; 848 | padding-right: 1rem; 849 | } 850 | 851 | .px-3 { 852 | padding-left: 0.75rem; 853 | padding-right: 0.75rem; 854 | } 855 | 856 | .py-2 { 857 | padding-top: 0.5rem; 858 | padding-bottom: 0.5rem; 859 | } 860 | 861 | .py-5 { 862 | padding-top: 1.25rem; 863 | padding-bottom: 1.25rem; 864 | } 865 | 866 | .pl-5 { 867 | padding-left: 1.25rem; 868 | } 869 | 870 | .pl-3 { 871 | padding-left: 0.75rem; 872 | } 873 | 874 | .text-center { 875 | text-align: center; 876 | } 877 | 878 | .text-3xl { 879 | font-size: 1.875rem; 880 | line-height: 2.25rem; 881 | } 882 | 883 | .text-sm { 884 | font-size: 0.875rem; 885 | line-height: 1.25rem; 886 | } 887 | 888 | .font-extrabold { 889 | font-weight: 800; 890 | } 891 | 892 | .font-medium { 893 | font-weight: 500; 894 | } 895 | 896 | .font-bold { 897 | font-weight: 700; 898 | } 899 | 900 | .leading-4 { 901 | line-height: 1rem; 902 | } 903 | 904 | .text-red-500 { 905 | --tw-text-opacity: 1; 906 | color: rgb(239 68 68 / var(--tw-text-opacity)); 907 | } 908 | 909 | .text-blue-400 { 910 | --tw-text-opacity: 1; 911 | color: rgb(96 165 250 / var(--tw-text-opacity)); 912 | } 913 | 914 | .text-gray-900 { 915 | --tw-text-opacity: 1; 916 | color: rgb(17 24 39 / var(--tw-text-opacity)); 917 | } 918 | 919 | .text-indigo-600 { 920 | --tw-text-opacity: 1; 921 | color: rgb(79 70 229 / var(--tw-text-opacity)); 922 | } 923 | 924 | .text-gray-700 { 925 | --tw-text-opacity: 1; 926 | color: rgb(55 65 81 / var(--tw-text-opacity)); 927 | } 928 | 929 | .text-red-400 { 930 | --tw-text-opacity: 1; 931 | color: rgb(248 113 113 / var(--tw-text-opacity)); 932 | } 933 | 934 | .text-red-800 { 935 | --tw-text-opacity: 1; 936 | color: rgb(153 27 27 / var(--tw-text-opacity)); 937 | } 938 | 939 | .text-red-700 { 940 | --tw-text-opacity: 1; 941 | color: rgb(185 28 28 / var(--tw-text-opacity)); 942 | } 943 | 944 | .text-yellow-400 { 945 | --tw-text-opacity: 1; 946 | color: rgb(250 204 21 / var(--tw-text-opacity)); 947 | } 948 | 949 | .text-yellow-800 { 950 | --tw-text-opacity: 1; 951 | color: rgb(133 77 14 / var(--tw-text-opacity)); 952 | } 953 | 954 | .text-yellow-700 { 955 | --tw-text-opacity: 1; 956 | color: rgb(161 98 7 / var(--tw-text-opacity)); 957 | } 958 | 959 | .text-white { 960 | --tw-text-opacity: 1; 961 | color: rgb(255 255 255 / var(--tw-text-opacity)); 962 | } 963 | 964 | .text-indigo-500 { 965 | --tw-text-opacity: 1; 966 | color: rgb(99 102 241 / var(--tw-text-opacity)); 967 | } 968 | 969 | .placeholder-gray-500::-moz-placeholder { 970 | --tw-placeholder-opacity: 1; 971 | color: rgb(107 114 128 / var(--tw-placeholder-opacity)); 972 | } 973 | 974 | .placeholder-gray-500:-ms-input-placeholder { 975 | --tw-placeholder-opacity: 1; 976 | color: rgb(107 114 128 / var(--tw-placeholder-opacity)); 977 | } 978 | 979 | .placeholder-gray-500::placeholder { 980 | --tw-placeholder-opacity: 1; 981 | color: rgb(107 114 128 / var(--tw-placeholder-opacity)); 982 | } 983 | 984 | .shadow-sm { 985 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 986 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 987 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 988 | } 989 | 990 | .shadow { 991 | --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 992 | --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 993 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 994 | } 995 | 996 | .hover\:bg-indigo-700:hover { 997 | --tw-bg-opacity: 1; 998 | background-color: rgb(67 56 202 / var(--tw-bg-opacity)); 999 | } 1000 | 1001 | .hover\:underline:hover { 1002 | -webkit-text-decoration-line: underline; 1003 | text-decoration-line: underline; 1004 | } 1005 | 1006 | .focus\:z-10:focus { 1007 | z-index: 10; 1008 | } 1009 | 1010 | .focus\:border-indigo-500:focus { 1011 | --tw-border-opacity: 1; 1012 | border-color: rgb(99 102 241 / var(--tw-border-opacity)); 1013 | } 1014 | 1015 | .focus\:outline-none:focus { 1016 | outline: 2px solid transparent; 1017 | outline-offset: 2px; 1018 | } 1019 | 1020 | .focus\:ring-2:focus { 1021 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1022 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1023 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1024 | } 1025 | 1026 | .focus\:ring-indigo-500:focus { 1027 | --tw-ring-opacity: 1; 1028 | --tw-ring-color: rgb(99 102 241 / var(--tw-ring-opacity)); 1029 | } 1030 | 1031 | .focus\:ring-offset-2:focus { 1032 | --tw-ring-offset-width: 2px; 1033 | } 1034 | 1035 | .group:hover .group-hover\:text-indigo-400 { 1036 | --tw-text-opacity: 1; 1037 | color: rgb(129 140 248 / var(--tw-text-opacity)); 1038 | } 1039 | 1040 | @media (min-width: 640px) { 1041 | .sm\:flex { 1042 | display: flex; 1043 | } 1044 | 1045 | .sm\:items-center { 1046 | align-items: center; 1047 | } 1048 | 1049 | .sm\:space-y-0 > :not([hidden]) ~ :not([hidden]) { 1050 | --tw-space-y-reverse: 0; 1051 | margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse))); 1052 | margin-bottom: calc(0px * var(--tw-space-y-reverse)); 1053 | } 1054 | 1055 | .sm\:space-x-10 > :not([hidden]) ~ :not([hidden]) { 1056 | --tw-space-x-reverse: 0; 1057 | margin-right: calc(2.5rem * var(--tw-space-x-reverse)); 1058 | margin-left: calc(2.5rem * calc(1 - var(--tw-space-x-reverse))); 1059 | } 1060 | 1061 | .sm\:p-6 { 1062 | padding: 1.5rem; 1063 | } 1064 | 1065 | .sm\:px-6 { 1066 | padding-left: 1.5rem; 1067 | padding-right: 1.5rem; 1068 | } 1069 | 1070 | .sm\:text-sm { 1071 | font-size: 0.875rem; 1072 | line-height: 1.25rem; 1073 | } 1074 | } 1075 | 1076 | @media (min-width: 1024px) { 1077 | .lg\:px-8 { 1078 | padding-left: 2rem; 1079 | padding-right: 2rem; 1080 | } 1081 | } --------------------------------------------------------------------------------