├── .eslintrc ├── .gitignore ├── public └── favicon.ico ├── remix.env.d.ts ├── app ├── entry.client.tsx ├── models │ └── user.ts ├── routes │ ├── auth.github.callback.tsx │ ├── auth.github.tsx │ ├── shortlinks.tsx │ ├── logout.tsx │ ├── shortlinks │ │ ├── $slug.tsx │ │ └── index.tsx │ ├── $slug.tsx │ └── index.tsx ├── utils │ └── db.server.ts ├── services │ ├── session.server.ts │ └── auth.server.ts ├── entry.server.tsx ├── components │ └── home │ │ ├── unauthenticated.tsx │ │ └── authenticated.tsx ├── root.tsx └── tailwind.css ├── tailwind.config.js ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20220118030836_create_shortlinks_table │ │ └── migration.sql └── schema.prisma ├── .env.example ├── remix.config.js ├── tsconfig.json ├── package.json └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kavinvalli/remix-url-shortener/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /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/**/*.{tsx,ts,jsx,js}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /app/models/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | name: string; 3 | email: string; 4 | } 5 | 6 | export async function login(name: string, email: string): Promise { 7 | return { name, email }; 8 | } 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="mysql://root:@localhost:3306/remixurl?schema=public" 2 | GITHUB_CLIENT_ID="" 3 | GITHUB_CLIENT_SECRET="" 4 | COOKIE_SECRET="anyrandomstringisfinehere" 5 | APP_URL="http://localhost:3000" 6 | ALLOWED_EMAIL="" # The email which is associated with the github account you want to allow to login 7 | -------------------------------------------------------------------------------- /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/auth.github.callback.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderFunction } from "remix"; 2 | import { authenticator } from "~/services/auth.server"; 3 | 4 | export const loader: LoaderFunction = ({ request }) => { 5 | return authenticator.authenticate("github", request, { 6 | successRedirect: "/", 7 | failureRedirect: "/", 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /app/routes/auth.github.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, LoaderFunction, redirect } from "remix"; 2 | import { authenticator } from "~/services/auth.server"; 3 | 4 | export const loader: LoaderFunction = () => redirect("/"); 5 | 6 | export const action: ActionFunction = async ({ request }) => { 7 | return await authenticator.authenticate("github", request); 8 | }; 9 | -------------------------------------------------------------------------------- /app/routes/shortlinks.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "remix"; 2 | 3 | const ShortlinksRoute = () => { 4 | return ( 5 |
6 |

Shortlinks

7 |
8 | 9 |
10 |
11 | ); 12 | }; 13 | 14 | export default ShortlinksRoute; 15 | -------------------------------------------------------------------------------- /app/utils/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let db: PrismaClient; 4 | 5 | declare global { 6 | var __db: PrismaClient | undefined; 7 | } 8 | 9 | if (process.env.NODE_ENV === "production") { 10 | db = new PrismaClient(); 11 | db.$connect(); 12 | } else { 13 | if (!global.__db) { 14 | global.__db = new PrismaClient(); 15 | global.__db.$connect(); 16 | } 17 | db = global.__db; 18 | } 19 | 20 | export { db }; 21 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, json, LoaderFunction, redirect } from "remix"; 2 | import { destroySession, getSession } from "~/services/session.server"; 3 | 4 | export let action: ActionFunction = async ({ request }) => { 5 | return redirect("/", { 6 | headers: { 7 | "Set-Cookie": await destroySession(await getSession(request)), 8 | }, 9 | }); 10 | }; 11 | 12 | export let loader: LoaderFunction = () => { 13 | throw json({}, { status: 404 }); 14 | }; 15 | -------------------------------------------------------------------------------- /prisma/migrations/20220118030836_create_shortlinks_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `Shortlink` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `slug` VARCHAR(191) NOT NULL, 5 | `target` VARCHAR(191) NOT NULL, 6 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 7 | `updatedAt` DATETIME(3) NOT NULL, 8 | 9 | UNIQUE INDEX `Shortlink_slug_key`(`slug`), 10 | PRIMARY KEY (`id`) 11 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 12 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Shortlink { 14 | id String @id @default(uuid()) 15 | slug String @unique 16 | target String 17 | createdAt DateTime @default(now()) 18 | updatedAt DateTime @updatedAt 19 | } 20 | -------------------------------------------------------------------------------- /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/services/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, Session } from "remix"; 2 | 3 | export let sessionStorage = createCookieSessionStorage({ 4 | cookie: { 5 | name: "__session", 6 | httpOnly: true, 7 | path: "/", 8 | sameSite: "lax", 9 | secrets: [process.env.COOKIE_SECRET ?? "s3cr3t"], 10 | secure: process.env.NODE_ENV === "production", 11 | }, 12 | }); 13 | 14 | export function getSession(request: Request): Promise { 15 | return sessionStorage.getSession(request.headers.get("Cookie")); 16 | } 17 | 18 | export let { commitSession, destroySession } = sessionStorage; 19 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import { RemixServer } from "remix"; 3 | import type { EntryContext } from "remix"; 4 | 5 | require("dotenv").config(); 6 | 7 | export default function handleRequest( 8 | request: Request, 9 | responseStatusCode: number, 10 | responseHeaders: Headers, 11 | remixContext: EntryContext 12 | ) { 13 | const markup = renderToString( 14 | 15 | ); 16 | 17 | responseHeaders.set("Content-Type", "text/html"); 18 | 19 | return new Response("" + markup, { 20 | status: responseStatusCode, 21 | headers: responseHeaders, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /app/services/auth.server.ts: -------------------------------------------------------------------------------- 1 | import { Authenticator, AuthorizationError } from "remix-auth"; 2 | import { GitHubStrategy } from "remix-auth-github"; 3 | import { login, User } from "~/models/user"; 4 | import { sessionStorage } from "./session.server"; 5 | 6 | export let authenticator = new Authenticator(sessionStorage); 7 | 8 | if (!process.env.GITHUB_CLIENT_ID) throw new Error("Missing GITHUB_CLIENT_ID"); 9 | if (!process.env.GITHUB_CLIENT_SECRET) 10 | throw new Error("Missing GITHUB_CLIENT_SECRET"); 11 | const { 12 | GITHUB_CLIENT_ID: clientID, 13 | GITHUB_CLIENT_SECRET: clientSecret, 14 | APP_URL, 15 | } = process.env; 16 | 17 | authenticator.use( 18 | new GitHubStrategy( 19 | { 20 | clientID, 21 | clientSecret, 22 | callbackURL: `${APP_URL}/auth/github/callback`, 23 | }, 24 | async (profile) => { 25 | if (profile.profile.emails[0].value === process.env.ALLOWED_EMAIL) 26 | return login( 27 | profile.profile.name.givenName, 28 | profile.profile.emails[0].value 29 | ); 30 | throw new AuthorizationError( 31 | "You're not allowed to login to this platform" 32 | ); 33 | } 34 | ) 35 | ); 36 | -------------------------------------------------------------------------------- /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/tailwind.css", 9 | "dev": "concurrently \"npm run dev:css\" \"remix dev\"", 10 | "dev:css": "tailwindcss -o ./app/tailwind.css --watch", 11 | "postinstall": "remix setup node", 12 | "start": "remix-serve build" 13 | }, 14 | "dependencies": { 15 | "@prisma/client": "^3.8.1", 16 | "@remix-run/react": "^1.1.1", 17 | "@remix-run/serve": "^1.1.1", 18 | "dotenv": "^14.2.0", 19 | "react": "^17.0.2", 20 | "react-dom": "^17.0.2", 21 | "react-hot-toast": "^2.2.0", 22 | "remix": "^1.1.1", 23 | "remix-auth": "^3.2.1", 24 | "remix-auth-github": "^1.0.0" 25 | }, 26 | "devDependencies": { 27 | "@remix-run/dev": "^1.1.1", 28 | "@types/react": "^17.0.24", 29 | "@types/react-dom": "^17.0.9", 30 | "concurrently": "^7.0.0", 31 | "prisma": "^3.8.1", 32 | "tailwindcss": "^3.0.15", 33 | "typescript": "^4.1.2" 34 | }, 35 | "engines": { 36 | "node": ">=14" 37 | }, 38 | "sideEffects": false 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix URL Shortener 2 | A simple URL shortener built using Remix using MySQL (you can change this in [prisma/schema.prisma](prisma/schema.prisma#L9-L10)). 3 | ## Setup 4 | - Setup env variables: 5 | ```sh 6 | cp .env.example .env 7 | ``` 8 | > Edit the variables accordingly 9 | 10 | - Install dependencies: 11 | ```sh 12 | npm install 13 | ``` 14 | - Push to db 15 | ```sh 16 | npx prisma db push 17 | ``` 18 | > Can use prisma migrations here but since it was only one table and a pretty small one, I decided to not use it 19 | 20 | ### Development 21 | 22 | From your terminal: 23 | 24 | ```sh 25 | npm run dev 26 | ``` 27 | 28 | This starts your app in development mode, rebuilding assets on file changes. 29 | 30 | ### Deployment 31 | 32 | First, build your app for production: 33 | 34 | ```sh 35 | npm run build 36 | ``` 37 | 38 | Then run the app in production mode: 39 | 40 | ```sh 41 | npm start 42 | ``` 43 | 44 | Now you'll need to pick a host to deploy it to. 45 | 46 | #### DIY 47 | 48 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 49 | 50 | Make sure to deploy the output of `remix build` 51 | 52 | - `build/` 53 | - `public/build/` 54 | -------------------------------------------------------------------------------- /app/routes/shortlinks/$slug.tsx: -------------------------------------------------------------------------------- 1 | import { CatchBoundaryComponent } from "@remix-run/react/routeModules"; 2 | import { LoaderFunction, useCatch, useLoaderData } from "remix"; 3 | import { db } from "~/utils/db.server"; 4 | 5 | export const loader: LoaderFunction = async ({ params }) => { 6 | const { slug } = params; 7 | const shortlink = await db.shortlink.findUnique({ where: { slug } }); 8 | if (!shortlink) { 9 | throw new Response("Shortlink not found", { status: 404 }); 10 | } 11 | 12 | return shortlink; 13 | }; 14 | 15 | const ShortlinkRoute = () => { 16 | const shortlink = useLoaderData(); 17 | return ( 18 |
19 |
20 |

Slug:

{shortlink.slug} 21 |
22 |
23 |

Target:

24 | 25 | {shortlink.target} 26 | 27 |
28 |
29 | ); 30 | }; 31 | 32 | export const CatchBoundary: CatchBoundaryComponent = () => { 33 | const caught = useCatch(); 34 | if (caught.status === 404) { 35 | return
Shortlink not found
; 36 | } 37 | return ( 38 |
39 |

Something went wrong

40 |
41 | ); 42 | }; 43 | 44 | export default ShortlinkRoute; 45 | -------------------------------------------------------------------------------- /app/components/home/unauthenticated.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from "remix"; 2 | 3 | const Unauthenticated = () => { 4 | return ( 5 |
6 | 19 |
20 | ); 21 | }; 22 | 23 | export default Unauthenticated; 24 | -------------------------------------------------------------------------------- /app/routes/$slug.tsx: -------------------------------------------------------------------------------- 1 | import { CatchBoundaryComponent } from "@remix-run/react/routeModules"; 2 | import { 3 | ErrorBoundaryComponent, 4 | LoaderFunction, 5 | redirect, 6 | useCatch, 7 | useParams, 8 | } from "remix"; 9 | import { db } from "~/utils/db.server"; 10 | 11 | export const loader: LoaderFunction = async ({ params }) => { 12 | const { slug } = params; 13 | const shortlink = await db.shortlink.findUnique({ where: { slug } }); 14 | if (!shortlink) { 15 | throw new Response("Shortlink not found", { status: 404 }); 16 | } 17 | return redirect(shortlink.target); 18 | }; 19 | 20 | // This is needed for remix to render the CatchBoundary 21 | export default function Slug() { 22 | return

Something

; 23 | } 24 | 25 | export const CatchBoundary: CatchBoundaryComponent = () => { 26 | const caught = useCatch(); 27 | const params = useParams(); 28 | if (caught.status === 404) { 29 | return ( 30 |
31 |

404

32 |

Shortlink {params.slug} not found

33 |
34 | ); 35 | } 36 | return ( 37 |
38 |

Something went wrong...

39 |
40 | ); 41 | }; 42 | 43 | export const ErrorBoundary: ErrorBoundaryComponent = () => { 44 | const params = useParams(); 45 | return ( 46 |
47 |

48 | There was an error loading shortlink {params.slug} 49 |

50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ErrorBoundaryComponent, 3 | Form, 4 | Link, 5 | Links, 6 | LiveReload, 7 | LoaderFunction, 8 | Meta, 9 | Outlet, 10 | Scripts, 11 | ScrollRestoration, 12 | useCatch, 13 | useLoaderData, 14 | } from "remix"; 15 | import type { MetaFunction } from "remix"; 16 | import { 17 | CatchBoundaryComponent, 18 | LinksFunction, 19 | } from "@remix-run/react/routeModules"; 20 | import tailwindStyles from "./tailwind.css"; 21 | import { authenticator } from "./services/auth.server"; 22 | import { User } from "./models/user"; 23 | 24 | export const meta: MetaFunction = () => { 25 | return { title: "New Remix App" }; 26 | }; 27 | 28 | export const links: LinksFunction = () => { 29 | return [{ rel: "stylesheet", href: tailwindStyles }]; 30 | }; 31 | 32 | export const loader: LoaderFunction = async ({ request }) => { 33 | const user = await authenticator.isAuthenticated(request); 34 | return { user }; 35 | }; 36 | 37 | export default function App() { 38 | const data = useLoaderData<{ user: User }>(); 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {data.user && ( 49 |
50 | 51 | Home 52 | 53 | 54 | Shortlinks 55 | 56 |
61 | 62 |
63 |
64 | )} 65 | 66 | 67 | 68 | {process.env.NODE_ENV === "development" && } 69 | 70 | 71 | ); 72 | } 73 | 74 | export const CatchBoundary: CatchBoundaryComponent = () => { 75 | const caught = useCatch(); 76 | return
{caught.status}
; 77 | }; 78 | 79 | export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => { 80 | return
{error}
; 81 | }; 82 | -------------------------------------------------------------------------------- /app/components/home/authenticated.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Form } from "remix"; 3 | import { User } from "~/models/user"; 4 | 5 | interface IAuthenticatedProps { 6 | user: User; 7 | slug: string | null | undefined; 8 | target: string | null | undefined; 9 | fieldErrors?: { 10 | slug: string | undefined; 11 | target: string | undefined; 12 | }; 13 | } 14 | 15 | const Authenticated: React.FC = ({ 16 | user, 17 | slug: _slug, 18 | target: _target, 19 | fieldErrors, 20 | }) => { 21 | const [slug, setSlug] = useState(_slug); 22 | const [target, setTarget] = useState(_target); 23 | useEffect(() => { 24 | setSlug(_slug); 25 | setTarget(_target); 26 | }, [_slug, _target]); 27 | return ( 28 |
32 |

33 | Hi {user.name} 34 |

35 |
36 | 43 | setSlug(e.target.value.split("/").join("").split(" ").join("")) 44 | } 45 | /> 46 | 52 | {fieldErrors?.slug && ( 53 |

{fieldErrors?.slug}

54 | )} 55 |
56 |
57 | setTarget(e.target.value.split(" ").join(""))} 65 | /> 66 | 72 | {fieldErrors?.target && ( 73 |

{fieldErrors?.target}

74 | )} 75 |
76 |
77 | 83 |
84 |
85 | ); 86 | }; 87 | 88 | export default Authenticated; 89 | -------------------------------------------------------------------------------- /app/routes/shortlinks/index.tsx: -------------------------------------------------------------------------------- 1 | import { CatchBoundaryComponent } from "@remix-run/react/routeModules"; 2 | import { 3 | ActionFunction, 4 | Link, 5 | LoaderFunction, 6 | useFetcher, 7 | useLoaderData, 8 | } from "remix"; 9 | import { authenticator } from "~/services/auth.server"; 10 | import { db } from "~/utils/db.server"; 11 | 12 | interface IShortlink { 13 | id: string; 14 | slug: string; 15 | target: string; 16 | } 17 | 18 | export const loader: LoaderFunction = async () => { 19 | try { 20 | const shortlinks: IShortlink[] = await db.shortlink.findMany(); 21 | return { shortlinks }; 22 | } catch (error) { 23 | throw new Response("Something went wrong", { status: 500 }); 24 | } 25 | }; 26 | 27 | export const action: ActionFunction = async ({ request }) => { 28 | const user = await authenticator.isAuthenticated(request); 29 | if (!user) { 30 | throw new Response("You're not authorized to create a shortlink", { 31 | status: 401, 32 | }); 33 | } 34 | const form = await request.formData(); 35 | const id = form.get("id")?.toString(); 36 | try { 37 | return db.shortlink.delete({ where: { id } }); 38 | } catch (error) { 39 | return { error: true, message: (error as Error).message }; 40 | } 41 | }; 42 | 43 | const Shortlinks = () => { 44 | const loaderData = useLoaderData<{ shortlinks: IShortlink[] }>(); 45 | 46 | return ( 47 |
48 | {loaderData.shortlinks.length > 0 ? ( 49 | loaderData.shortlinks.map((shortlink) => ( 50 | 51 | )) 52 | ) : ( 53 |
54 | Nothing there yet.... create one here 55 |
56 | )} 57 |
58 | ); 59 | }; 60 | 61 | const Shortlink: React.FC<{ shortlink: IShortlink }> = ({ shortlink }) => { 62 | const fetcher = useFetcher(); 63 | let isDeleting = fetcher.submission?.formData.get("id") === shortlink.id; 64 | let isFailedDeletion = fetcher.data?.error; 65 | 66 | return ( 67 | <> 68 | {isFailedDeletion && ( 69 |
70 | {fetcher.data?.message} 71 |
72 | )} 73 | 74 |
80 |

{shortlink.slug}

81 | 82 | 83 | 105 | 106 |
107 | 108 | 109 | ); 110 | }; 111 | 112 | export const CatchBoundary: CatchBoundaryComponent = () => { 113 | return ( 114 |
115 |

Something went wrong

116 |
117 | ); 118 | }; 119 | 120 | export default Shortlinks; 121 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { 3 | ActionFunction, 4 | ErrorBoundaryComponent, 5 | json, 6 | LoaderFunction, 7 | redirect, 8 | useActionData, 9 | useCatch, 10 | useLoaderData, 11 | } from "remix"; 12 | import Authenticated from "~/components/home/authenticated"; 13 | import Unauthenticated from "~/components/home/unauthenticated"; 14 | import { User } from "~/models/user"; 15 | import { authenticator } from "~/services/auth.server"; 16 | import { getSession } from "~/services/session.server"; 17 | import toast, { Toaster } from "react-hot-toast"; 18 | import { db } from "~/utils/db.server"; 19 | import { CatchBoundaryComponent } from "@remix-run/react/routeModules"; 20 | 21 | export const loader: LoaderFunction = async ({ request }) => { 22 | const user = await authenticator.isAuthenticated(request); 23 | const session = await getSession(request); 24 | const error = await session.get(authenticator.sessionErrorKey); 25 | return { user, error }; 26 | }; 27 | 28 | interface ActionData { 29 | fieldErrors?: { 30 | slug: string | undefined; 31 | target: string | undefined; 32 | }; 33 | fields?: { 34 | slug: string | null; 35 | target: string | null; 36 | }; 37 | success?: boolean; 38 | } 39 | 40 | function badRequest(data: ActionData) { 41 | return json(data, { status: 400 }); 42 | } 43 | 44 | async function validateSlug(slug: unknown) { 45 | if (typeof slug !== "string") { 46 | return `Slug must be a string of atleast 1 character`; 47 | } 48 | if (slug.includes("/") || slug.includes(" ")) { 49 | return `Slug cannot include any spaces or /`; 50 | } 51 | if ( 52 | await db.shortlink.findUnique({ 53 | where: { slug }, 54 | }) 55 | ) { 56 | return `Record with slug ${slug} already exists`; 57 | } 58 | } 59 | 60 | function validateTarget(target: unknown) { 61 | if (typeof target !== "string") { 62 | return `Target must be a string of atleast 1 character`; 63 | } 64 | const urlRegex = new RegExp( 65 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/ 66 | ); 67 | if (!target.match(urlRegex)) { 68 | return `Target should be a valid url`; 69 | } 70 | } 71 | 72 | export const action: ActionFunction = async ({ request }) => { 73 | const user = await authenticator.isAuthenticated(request); 74 | if (!user) { 75 | throw new Response("You're not authorized to create a shortlink", { 76 | status: 401, 77 | }); 78 | } 79 | const form = await request.formData(); 80 | const slug = form.get("slug"); 81 | const target = form.get("target"); 82 | const fieldErrors = { 83 | slug: await validateSlug(slug), 84 | target: validateTarget(target), 85 | }; 86 | const fields = { 87 | slug: slug ? String(slug) : "", 88 | target: target ? String(target) : "", 89 | }; 90 | if (Object.values(fieldErrors).some(Boolean)) { 91 | return badRequest({ fieldErrors, fields }); 92 | } 93 | 94 | await db.shortlink.create({ data: fields }); 95 | return { success: true, fields: { slug: "", target: "" } }; 96 | }; 97 | 98 | export default function Index() { 99 | const loaderData = useLoaderData<{ user: User; error: any }>(); 100 | const actionData = useActionData(); 101 | useEffect(() => { 102 | if (loaderData.error && loaderData.error.message) { 103 | toast.error(loaderData.error.message, { 104 | style: { 105 | background: "#11171f", 106 | color: "#fff", 107 | }, 108 | }); 109 | } 110 | 111 | if (actionData?.success) { 112 | toast.success("Successfully created shortlink"); 113 | } 114 | }, [actionData]); 115 | return ( 116 | <> 117 | 118 | {loaderData.user ? ( 119 | 125 | ) : ( 126 | 127 | )} 128 | 129 | ); 130 | } 131 | 132 | export const CatchBoundary: CatchBoundaryComponent = () => { 133 | const caught = useCatch(); 134 | if (caught.status === 401) { 135 | return

{caught.data}

; 136 | } 137 | return
Something went wrong
; 138 | }; 139 | 140 | export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => { 141 | return
{error}
; 142 | }; 143 | -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.0.15 | 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: #e5e7eb; 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 | *, ::before, ::after { 427 | --tw-translate-x: 0; 428 | --tw-translate-y: 0; 429 | --tw-rotate: 0; 430 | --tw-skew-x: 0; 431 | --tw-skew-y: 0; 432 | --tw-scale-x: 1; 433 | --tw-scale-y: 1; 434 | --tw-pan-x: ; 435 | --tw-pan-y: ; 436 | --tw-pinch-zoom: ; 437 | --tw-scroll-snap-strictness: proximity; 438 | --tw-ordinal: ; 439 | --tw-slashed-zero: ; 440 | --tw-numeric-figure: ; 441 | --tw-numeric-spacing: ; 442 | --tw-numeric-fraction: ; 443 | --tw-ring-inset: ; 444 | --tw-ring-offset-width: 0px; 445 | --tw-ring-offset-color: #fff; 446 | --tw-ring-color: rgb(59 130 246 / 0.5); 447 | --tw-ring-offset-shadow: 0 0 #0000; 448 | --tw-ring-shadow: 0 0 #0000; 449 | --tw-shadow: 0 0 #0000; 450 | --tw-shadow-colored: 0 0 #0000; 451 | --tw-blur: ; 452 | --tw-brightness: ; 453 | --tw-contrast: ; 454 | --tw-grayscale: ; 455 | --tw-hue-rotate: ; 456 | --tw-invert: ; 457 | --tw-saturate: ; 458 | --tw-sepia: ; 459 | --tw-drop-shadow: ; 460 | --tw-backdrop-blur: ; 461 | --tw-backdrop-brightness: ; 462 | --tw-backdrop-contrast: ; 463 | --tw-backdrop-grayscale: ; 464 | --tw-backdrop-hue-rotate: ; 465 | --tw-backdrop-invert: ; 466 | --tw-backdrop-opacity: ; 467 | --tw-backdrop-saturate: ; 468 | --tw-backdrop-sepia: ; 469 | } 470 | 471 | .absolute { 472 | position: absolute; 473 | } 474 | 475 | .relative { 476 | position: relative; 477 | } 478 | 479 | .top-5 { 480 | top: 1.25rem; 481 | } 482 | 483 | .right-5 { 484 | right: 1.25rem; 485 | } 486 | 487 | .left-0 { 488 | left: 0px; 489 | } 490 | 491 | .-top-3\.5 { 492 | top: -0.875rem; 493 | } 494 | 495 | .-top-3 { 496 | top: -0.75rem; 497 | } 498 | 499 | .m-2 { 500 | margin: 0.5rem; 501 | } 502 | 503 | .mx-4 { 504 | margin-left: 1rem; 505 | margin-right: 1rem; 506 | } 507 | 508 | .my-2 { 509 | margin-top: 0.5rem; 510 | margin-bottom: 0.5rem; 511 | } 512 | 513 | .mb-6 { 514 | margin-bottom: 1.5rem; 515 | } 516 | 517 | .mb-4 { 518 | margin-bottom: 1rem; 519 | } 520 | 521 | .mr-2 { 522 | margin-right: 0.5rem; 523 | } 524 | 525 | .mb-2 { 526 | margin-bottom: 0.5rem; 527 | } 528 | 529 | .mb-3 { 530 | margin-bottom: 0.75rem; 531 | } 532 | 533 | .inline { 534 | display: inline; 535 | } 536 | 537 | .flex { 538 | display: flex; 539 | } 540 | 541 | .hidden { 542 | display: none; 543 | } 544 | 545 | .h-full { 546 | height: 100%; 547 | } 548 | 549 | .h-10 { 550 | height: 2.5rem; 551 | } 552 | 553 | .h-6 { 554 | height: 1.5rem; 555 | } 556 | 557 | .min-h-screen { 558 | min-height: 100vh; 559 | } 560 | 561 | .w-full { 562 | width: 100%; 563 | } 564 | 565 | .w-6 { 566 | width: 1.5rem; 567 | } 568 | 569 | .max-w-md { 570 | max-width: 28rem; 571 | } 572 | 573 | .items-center { 574 | align-items: center; 575 | } 576 | 577 | .justify-end { 578 | justify-content: flex-end; 579 | } 580 | 581 | .justify-center { 582 | justify-content: center; 583 | } 584 | 585 | .justify-between { 586 | justify-content: space-between; 587 | } 588 | 589 | .rounded { 590 | border-radius: 0.25rem; 591 | } 592 | 593 | .rounded-md { 594 | border-radius: 0.375rem; 595 | } 596 | 597 | .rounded-sm { 598 | border-radius: 0.125rem; 599 | } 600 | 601 | .border { 602 | border-width: 1px; 603 | } 604 | 605 | .border-b-2 { 606 | border-bottom-width: 2px; 607 | } 608 | 609 | .border-gray-400 { 610 | --tw-border-opacity: 1; 611 | border-color: rgb(156 163 175 / var(--tw-border-opacity)); 612 | } 613 | 614 | .border-red-400 { 615 | --tw-border-opacity: 1; 616 | border-color: rgb(248 113 113 / var(--tw-border-opacity)); 617 | } 618 | 619 | .bg-gray-800 { 620 | --tw-bg-opacity: 1; 621 | background-color: rgb(31 41 55 / var(--tw-bg-opacity)); 622 | } 623 | 624 | .bg-gray-700 { 625 | --tw-bg-opacity: 1; 626 | background-color: rgb(55 65 81 / var(--tw-bg-opacity)); 627 | } 628 | 629 | .bg-transparent { 630 | background-color: transparent; 631 | } 632 | 633 | .bg-red-300 { 634 | --tw-bg-opacity: 1; 635 | background-color: rgb(252 165 165 / var(--tw-bg-opacity)); 636 | } 637 | 638 | .bg-red-500 { 639 | --tw-bg-opacity: 1; 640 | background-color: rgb(239 68 68 / var(--tw-bg-opacity)); 641 | } 642 | 643 | .bg-opacity-90 { 644 | --tw-bg-opacity: 0.9; 645 | } 646 | 647 | .p-4 { 648 | padding: 1rem; 649 | } 650 | 651 | .p-12 { 652 | padding: 3rem; 653 | } 654 | 655 | .p-6 { 656 | padding: 1.5rem; 657 | } 658 | 659 | .p-2 { 660 | padding: 0.5rem; 661 | } 662 | 663 | .px-4 { 664 | padding-left: 1rem; 665 | padding-right: 1rem; 666 | } 667 | 668 | .py-3 { 669 | padding-top: 0.75rem; 670 | padding-bottom: 0.75rem; 671 | } 672 | 673 | .px-6 { 674 | padding-left: 1.5rem; 675 | padding-right: 1.5rem; 676 | } 677 | 678 | .py-5 { 679 | padding-top: 1.25rem; 680 | padding-bottom: 1.25rem; 681 | } 682 | 683 | .py-4 { 684 | padding-top: 1rem; 685 | padding-bottom: 1rem; 686 | } 687 | 688 | .text-3xl { 689 | font-size: 1.875rem; 690 | line-height: 2.25rem; 691 | } 692 | 693 | .text-lg { 694 | font-size: 1.125rem; 695 | line-height: 1.75rem; 696 | } 697 | 698 | .text-2xl { 699 | font-size: 1.5rem; 700 | line-height: 2rem; 701 | } 702 | 703 | .text-sm { 704 | font-size: 0.875rem; 705 | line-height: 1.25rem; 706 | } 707 | 708 | .text-xs { 709 | font-size: 0.75rem; 710 | line-height: 1rem; 711 | } 712 | 713 | .text-xl { 714 | font-size: 1.25rem; 715 | line-height: 1.75rem; 716 | } 717 | 718 | .font-bold { 719 | font-weight: 700; 720 | } 721 | 722 | .uppercase { 723 | text-transform: uppercase; 724 | } 725 | 726 | .text-white { 727 | --tw-text-opacity: 1; 728 | color: rgb(255 255 255 / var(--tw-text-opacity)); 729 | } 730 | 731 | .text-red-500 { 732 | --tw-text-opacity: 1; 733 | color: rgb(239 68 68 / var(--tw-text-opacity)); 734 | } 735 | 736 | .text-red-900 { 737 | --tw-text-opacity: 1; 738 | color: rgb(127 29 29 / var(--tw-text-opacity)); 739 | } 740 | 741 | .text-blue-500 { 742 | --tw-text-opacity: 1; 743 | color: rgb(59 130 246 / var(--tw-text-opacity)); 744 | } 745 | 746 | .underline { 747 | -webkit-text-decoration-line: underline; 748 | text-decoration-line: underline; 749 | } 750 | 751 | .placeholder-transparent::-moz-placeholder { 752 | color: transparent; 753 | } 754 | 755 | .placeholder-transparent:-ms-input-placeholder { 756 | color: transparent; 757 | } 758 | 759 | .placeholder-transparent::placeholder { 760 | color: transparent; 761 | } 762 | 763 | .transition-all { 764 | transition-property: all; 765 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 766 | transition-duration: 150ms; 767 | } 768 | 769 | .focus\:border-white:focus { 770 | --tw-border-opacity: 1; 771 | border-color: rgb(255 255 255 / var(--tw-border-opacity)); 772 | } 773 | 774 | .focus\:outline-none:focus { 775 | outline: 2px solid transparent; 776 | outline-offset: 2px; 777 | } 778 | 779 | .peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:top-2 { 780 | top: 0.5rem; 781 | } 782 | 783 | .peer:-ms-input-placeholder ~ .peer-placeholder-shown\:top-2 { 784 | top: 0.5rem; 785 | } 786 | 787 | .peer:placeholder-shown ~ .peer-placeholder-shown\:top-2 { 788 | top: 0.5rem; 789 | } 790 | 791 | .peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:text-base { 792 | font-size: 1rem; 793 | line-height: 1.5rem; 794 | } 795 | 796 | .peer:-ms-input-placeholder ~ .peer-placeholder-shown\:text-base { 797 | font-size: 1rem; 798 | line-height: 1.5rem; 799 | } 800 | 801 | .peer:placeholder-shown ~ .peer-placeholder-shown\:text-base { 802 | font-size: 1rem; 803 | line-height: 1.5rem; 804 | } 805 | 806 | .peer:-moz-placeholder-shown ~ .peer-placeholder-shown\:text-gray-400 { 807 | --tw-text-opacity: 1; 808 | color: rgb(156 163 175 / var(--tw-text-opacity)); 809 | } 810 | 811 | .peer:-ms-input-placeholder ~ .peer-placeholder-shown\:text-gray-400 { 812 | --tw-text-opacity: 1; 813 | color: rgb(156 163 175 / var(--tw-text-opacity)); 814 | } 815 | 816 | .peer:placeholder-shown ~ .peer-placeholder-shown\:text-gray-400 { 817 | --tw-text-opacity: 1; 818 | color: rgb(156 163 175 / var(--tw-text-opacity)); 819 | } 820 | 821 | .peer:focus ~ .peer-focus\:-top-3\.5 { 822 | top: -0.875rem; 823 | } 824 | 825 | .peer:focus ~ .peer-focus\:-top-3 { 826 | top: -0.75rem; 827 | } 828 | 829 | .peer:focus ~ .peer-focus\:text-sm { 830 | font-size: 0.875rem; 831 | line-height: 1.25rem; 832 | } 833 | 834 | .peer:focus ~ .peer-focus\:text-gray-400 { 835 | --tw-text-opacity: 1; 836 | color: rgb(156 163 175 / var(--tw-text-opacity)); 837 | } --------------------------------------------------------------------------------