(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 |
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 |
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 |
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 |
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 | }
--------------------------------------------------------------------------------