snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
9
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/db.server.ts
vendored
Normal file
9
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/db.server.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { singleton } from "./singleton.server";
|
||||
|
||||
// Hard-code a unique key, so we can look up the client when this module gets re-imported
|
||||
const prisma = singleton("prisma", () => new PrismaClient());
|
||||
prisma.$connect();
|
||||
|
||||
export { prisma };
|
||||
18
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/entry.client.tsx
vendored
Normal file
18
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/entry.client.tsx
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* By default, Remix will handle hydrating your app on the client for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
|
||||
*/
|
||||
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
120
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/entry.server.tsx
vendored
Normal file
120
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/entry.server.tsx
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* By default, Remix will handle generating the HTTP Response for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
|
||||
*/
|
||||
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { EntryContext } from "@remix-run/node";
|
||||
import { createReadableStreamFromReadable } from "@remix-run/node";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { isbot } from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
const ABORT_DELAY = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
return isbot(request.headers.get("user-agent"))
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext,
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext,
|
||||
);
|
||||
}
|
||||
|
||||
function handleBotRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { abort, pipe } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onAllReady() {
|
||||
const body = new PassThrough();
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(createReadableStreamFromReadable(body), {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
console.error(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { abort, pipe } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onShellReady() {
|
||||
const body = new PassThrough();
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(createReadableStreamFromReadable(body), {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
console.error(error);
|
||||
responseStatusCode = 500;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
52
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/models/note.server.ts
vendored
Normal file
52
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/models/note.server.ts
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { User, Note } from "@prisma/client";
|
||||
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export function getNote({
|
||||
id,
|
||||
userId,
|
||||
}: Pick<Note, "id"> & {
|
||||
userId: User["id"];
|
||||
}) {
|
||||
return prisma.note.findFirst({
|
||||
select: { id: true, body: true, title: true },
|
||||
where: { id, userId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getNoteListItems({ userId }: { userId: User["id"] }) {
|
||||
return prisma.note.findMany({
|
||||
where: { userId },
|
||||
select: { id: true, title: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export function createNote({
|
||||
body,
|
||||
title,
|
||||
userId,
|
||||
}: Pick<Note, "body" | "title"> & {
|
||||
userId: User["id"];
|
||||
}) {
|
||||
return prisma.note.create({
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteNote({
|
||||
id,
|
||||
userId,
|
||||
}: Pick<Note, "id"> & { userId: User["id"] }) {
|
||||
return prisma.note.deleteMany({
|
||||
where: { id, userId },
|
||||
});
|
||||
}
|
||||
63
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/models/user.server.ts
vendored
Normal file
63
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/models/user.server.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Password, User } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export type { User } from "@prisma/client";
|
||||
|
||||
export async function getUserById(id: User["id"]) {
|
||||
return prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: User["email"]) {
|
||||
return prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
export async function createUser(email: User["email"], password: string) {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: {
|
||||
create: {
|
||||
hash: hashedPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUserByEmail(email: User["email"]) {
|
||||
return prisma.user.delete({ where: { email } });
|
||||
}
|
||||
|
||||
export async function verifyLogin(
|
||||
email: User["email"],
|
||||
password: Password["hash"],
|
||||
) {
|
||||
const userWithPassword = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: {
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userWithPassword || !userWithPassword.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(
|
||||
password,
|
||||
userWithPassword.password.hash,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { password: _password, ...userWithoutPassword } = userWithPassword;
|
||||
|
||||
return userWithoutPassword;
|
||||
}
|
||||
42
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/root.tsx
vendored
Normal file
42
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/root.tsx
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cssBundleHref } from "@remix-run/css-bundle";
|
||||
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import {
|
||||
Links,
|
||||
LiveReload,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "@remix-run/react";
|
||||
|
||||
import { getUser } from "~/session.server";
|
||||
import stylesheet from "~/tailwind.css";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: stylesheet },
|
||||
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
|
||||
];
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
return json({ user: await getUser(request) });
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<html lang="en" className="h-full">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="h-full">
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<LiveReload />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
141
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/_index.tsx
vendored
Normal file
141
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/_index.tsx
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
import { useOptionalUser } from "~/utils";
|
||||
|
||||
export const meta: MetaFunction = () => [{ title: "Remix Notes" }];
|
||||
|
||||
export default function Index() {
|
||||
const user = useOptionalUser();
|
||||
return (
|
||||
<main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center">
|
||||
<div className="relative sm:pb-16 sm:pt-8">
|
||||
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<div className="relative shadow-xl sm:overflow-hidden sm:rounded-2xl">
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src="https://user-images.githubusercontent.com/1500684/157774694-99820c51-8165-4908-a031-34fc371ac0d6.jpg"
|
||||
alt="Sonic Youth On Stage"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[color:rgba(254,204,27,0.5)] mix-blend-multiply" />
|
||||
</div>
|
||||
<div className="relative px-4 pb-8 pt-16 sm:px-6 sm:pb-14 sm:pt-24 lg:px-8 lg:pb-20 lg:pt-32">
|
||||
<h1 className="text-center text-6xl font-extrabold tracking-tight sm:text-8xl lg:text-9xl">
|
||||
<span className="block uppercase text-yellow-500 drop-shadow-md">
|
||||
Indie Stack
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-lg text-center text-xl text-white sm:max-w-3xl">
|
||||
Check the README.md file for instructions on how to get this
|
||||
project deployed.
|
||||
</p>
|
||||
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
|
||||
{user ? (
|
||||
<Link
|
||||
to="/notes"
|
||||
className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
|
||||
>
|
||||
View Notes for {user.email}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
|
||||
<Link
|
||||
to="/join"
|
||||
className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center justify-center rounded-md bg-yellow-500 px-4 py-3 font-medium text-white hover:bg-yellow-600"
|
||||
>
|
||||
Log In
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a href="https://remix.run">
|
||||
<img
|
||||
src="https://user-images.githubusercontent.com/1500684/158298926-e45dafff-3544-4b69-96d6-d3bcc33fc76a.svg"
|
||||
alt="Remix"
|
||||
className="mx-auto mt-16 w-full max-w-[12rem] md:max-w-[16rem]"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-8">
|
||||
{[
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg",
|
||||
alt: "Fly.io",
|
||||
href: "https://fly.io",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg",
|
||||
alt: "SQLite",
|
||||
href: "https://sqlite.org",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg",
|
||||
alt: "Prisma",
|
||||
href: "https://prisma.io",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg",
|
||||
alt: "Tailwind",
|
||||
href: "https://tailwindcss.com",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg",
|
||||
alt: "Cypress",
|
||||
href: "https://www.cypress.io",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg",
|
||||
alt: "MSW",
|
||||
href: "https://mswjs.io",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg",
|
||||
alt: "Vitest",
|
||||
href: "https://vitest.dev",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png",
|
||||
alt: "Testing Library",
|
||||
href: "https://testing-library.com",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg",
|
||||
alt: "Prettier",
|
||||
href: "https://prettier.io",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg",
|
||||
alt: "ESLint",
|
||||
href: "https://eslint.org",
|
||||
},
|
||||
{
|
||||
src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg",
|
||||
alt: "TypeScript",
|
||||
href: "https://typescriptlang.org",
|
||||
},
|
||||
].map((img) => (
|
||||
<a
|
||||
key={img.href}
|
||||
href={img.href}
|
||||
className="flex h-16 w-32 justify-center p-1 grayscale transition hover:grayscale-0 focus:grayscale-0"
|
||||
>
|
||||
<img alt={img.alt} src={img.src} className="object-contain" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
25
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/healthcheck.tsx
vendored
Normal file
25
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/healthcheck.tsx
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
// learn more: https://fly.io/docs/reference/configuration/#services-http_checks
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const host =
|
||||
request.headers.get("X-Forwarded-Host") ?? request.headers.get("host");
|
||||
|
||||
try {
|
||||
const url = new URL("/", `http://${host}`);
|
||||
// if we can connect to the database and make a simple query
|
||||
// and make a HEAD request to ourselves, then we're good.
|
||||
await Promise.all([
|
||||
prisma.user.count(),
|
||||
fetch(url.toString(), { method: "HEAD" }).then((r) => {
|
||||
if (!r.ok) return Promise.reject(r);
|
||||
}),
|
||||
]);
|
||||
return new Response("OK");
|
||||
} catch (error: unknown) {
|
||||
console.log("healthcheck ❌", { error });
|
||||
return new Response("ERROR", { status: 500 });
|
||||
}
|
||||
};
|
||||
171
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/join.tsx
vendored
Normal file
171
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/join.tsx
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { createUser, getUserByEmail } from "~/models/user.server";
|
||||
import { createUserSession, getUserId } from "~/session.server";
|
||||
import { safeRedirect, validateEmail } from "~/utils";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userId = await getUserId(request);
|
||||
if (userId) return redirect("/");
|
||||
return json({});
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
return json(
|
||||
{ errors: { email: "Email is invalid", password: null } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
return json(
|
||||
{ errors: { email: null, password: "Password is required" } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return json(
|
||||
{ errors: { email: null, password: "Password is too short" } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
return json(
|
||||
{
|
||||
errors: {
|
||||
email: "A user already exists with this email",
|
||||
password: null,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const user = await createUser(email, password);
|
||||
|
||||
return createUserSession({
|
||||
redirectTo,
|
||||
remember: false,
|
||||
request,
|
||||
userId: user.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = () => [{ title: "Sign Up" }];
|
||||
|
||||
export default function Join() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirectTo") ?? undefined;
|
||||
const actionData = useActionData<typeof action>();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.errors?.email) {
|
||||
emailRef.current?.focus();
|
||||
} else if (actionData?.errors?.password) {
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col justify-center">
|
||||
<div className="mx-auto w-full max-w-md px-8">
|
||||
<Form method="post" className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
ref={emailRef}
|
||||
id="email"
|
||||
required
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={true}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
aria-invalid={actionData?.errors?.email ? true : undefined}
|
||||
aria-describedby="email-error"
|
||||
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||
/>
|
||||
{actionData?.errors?.email ? (
|
||||
<div className="pt-1 text-red-700" id="email-error">
|
||||
{actionData.errors.email}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password"
|
||||
ref={passwordRef}
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={actionData?.errors?.password ? true : undefined}
|
||||
aria-describedby="password-error"
|
||||
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||
/>
|
||||
{actionData?.errors?.password ? (
|
||||
<div className="pt-1 text-red-700" id="password-error">
|
||||
{actionData.errors.password}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
className="text-blue-500 underline"
|
||||
to={{
|
||||
pathname: "/login",
|
||||
search: searchParams.toString(),
|
||||
}}
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/login.tsx
vendored
Normal file
180
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/login.tsx
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { verifyLogin } from "~/models/user.server";
|
||||
import { createUserSession, getUserId } from "~/session.server";
|
||||
import { safeRedirect, validateEmail } from "~/utils";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userId = await getUserId(request);
|
||||
if (userId) return redirect("/");
|
||||
return json({});
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get("email");
|
||||
const password = formData.get("password");
|
||||
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
|
||||
const remember = formData.get("remember");
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
return json(
|
||||
{ errors: { email: "Email is invalid", password: null } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
return json(
|
||||
{ errors: { email: null, password: "Password is required" } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return json(
|
||||
{ errors: { email: null, password: "Password is too short" } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const user = await verifyLogin(email, password);
|
||||
|
||||
if (!user) {
|
||||
return json(
|
||||
{ errors: { email: "Invalid email or password", password: null } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return createUserSession({
|
||||
redirectTo,
|
||||
remember: remember === "on" ? true : false,
|
||||
request,
|
||||
userId: user.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = () => [{ title: "Login" }];
|
||||
|
||||
export default function LoginPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirectTo") || "/notes";
|
||||
const actionData = useActionData<typeof action>();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.errors?.email) {
|
||||
emailRef.current?.focus();
|
||||
} else if (actionData?.errors?.password) {
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col justify-center">
|
||||
<div className="mx-auto w-full max-w-md px-8">
|
||||
<Form method="post" className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
ref={emailRef}
|
||||
id="email"
|
||||
required
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={true}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
aria-invalid={actionData?.errors?.email ? true : undefined}
|
||||
aria-describedby="email-error"
|
||||
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||
/>
|
||||
{actionData?.errors?.email ? (
|
||||
<div className="pt-1 text-red-700" id="email-error">
|
||||
{actionData.errors.email}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password"
|
||||
ref={passwordRef}
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
aria-invalid={actionData?.errors?.password ? true : undefined}
|
||||
aria-describedby="password-error"
|
||||
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||
/>
|
||||
{actionData?.errors?.password ? (
|
||||
<div className="pt-1 text-red-700" id="password-error">
|
||||
{actionData.errors.password}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember"
|
||||
name="remember"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember"
|
||||
className="ml-2 block text-sm text-gray-900"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
className="text-blue-500 underline"
|
||||
to={{
|
||||
pathname: "/join",
|
||||
search: searchParams.toString(),
|
||||
}}
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/logout.tsx
vendored
Normal file
9
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/logout.tsx
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
|
||||
import { logout } from "~/session.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) =>
|
||||
logout(request);
|
||||
|
||||
export const loader = async () => redirect("/");
|
||||
70
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/notes.$noteId.tsx
vendored
Normal file
70
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/notes.$noteId.tsx
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import {
|
||||
Form,
|
||||
isRouteErrorResponse,
|
||||
useLoaderData,
|
||||
useRouteError,
|
||||
} from "@remix-run/react";
|
||||
import invariant from "tiny-invariant";
|
||||
|
||||
import { deleteNote, getNote } from "~/models/note.server";
|
||||
import { requireUserId } from "~/session.server";
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
invariant(params.noteId, "noteId not found");
|
||||
|
||||
const note = await getNote({ id: params.noteId, userId });
|
||||
if (!note) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return json({ note });
|
||||
};
|
||||
|
||||
export const action = async ({ params, request }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
invariant(params.noteId, "noteId not found");
|
||||
|
||||
await deleteNote({ id: params.noteId, userId });
|
||||
|
||||
return redirect("/notes");
|
||||
};
|
||||
|
||||
export default function NoteDetailsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold">{data.note.title}</h3>
|
||||
<p className="py-6">{data.note.body}</p>
|
||||
<hr className="my-4" />
|
||||
<Form method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
|
||||
if (error instanceof Error) {
|
||||
return <div>An unexpected error occurred: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (!isRouteErrorResponse(error)) {
|
||||
return <h1>Unknown Error</h1>;
|
||||
}
|
||||
|
||||
if (error.status === 404) {
|
||||
return <div>Note not found</div>;
|
||||
}
|
||||
|
||||
return <div>An unexpected error occurred: {error.statusText}</div>;
|
||||
}
|
||||
12
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/notes._index.tsx
vendored
Normal file
12
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/notes._index.tsx
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
export default function NoteIndexPage() {
|
||||
return (
|
||||
<p>
|
||||
No note selected. Select a note on the left, or{" "}
|
||||
<Link to="new" className="text-blue-500 underline">
|
||||
create a new note.
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
109
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/notes.new.tsx
vendored
Normal file
109
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/notes.new.tsx
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { createNote } from "~/models/note.server";
|
||||
import { requireUserId } from "~/session.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const title = formData.get("title");
|
||||
const body = formData.get("body");
|
||||
|
||||
if (typeof title !== "string" || title.length === 0) {
|
||||
return json(
|
||||
{ errors: { body: null, title: "Title is required" } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof body !== "string" || body.length === 0) {
|
||||
return json(
|
||||
{ errors: { body: "Body is required", title: null } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const note = await createNote({ body, title, userId });
|
||||
|
||||
return redirect(`/notes/${note.id}`);
|
||||
};
|
||||
|
||||
export default function NewNotePage() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.errors?.title) {
|
||||
titleRef.current?.focus();
|
||||
} else if (actionData?.errors?.body) {
|
||||
bodyRef.current?.focus();
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
method="post"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="flex w-full flex-col gap-1">
|
||||
<span>Title: </span>
|
||||
<input
|
||||
ref={titleRef}
|
||||
name="title"
|
||||
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
|
||||
aria-invalid={actionData?.errors?.title ? true : undefined}
|
||||
aria-errormessage={
|
||||
actionData?.errors?.title ? "title-error" : undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{actionData?.errors?.title ? (
|
||||
<div className="pt-1 text-red-700" id="title-error">
|
||||
{actionData.errors.title}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex w-full flex-col gap-1">
|
||||
<span>Body: </span>
|
||||
<textarea
|
||||
ref={bodyRef}
|
||||
name="body"
|
||||
rows={8}
|
||||
className="w-full flex-1 rounded-md border-2 border-blue-500 px-3 py-2 text-lg leading-6"
|
||||
aria-invalid={actionData?.errors?.body ? true : undefined}
|
||||
aria-errormessage={
|
||||
actionData?.errors?.body ? "body-error" : undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{actionData?.errors?.body ? (
|
||||
<div className="pt-1 text-red-700" id="body-error">
|
||||
{actionData.errors.body}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
70
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/notes.tsx
vendored
Normal file
70
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/routes/notes.tsx
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react";
|
||||
|
||||
import { getNoteListItems } from "~/models/note.server";
|
||||
import { requireUserId } from "~/session.server";
|
||||
import { useUser } from "~/utils";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const noteListItems = await getNoteListItems({ userId });
|
||||
return json({ noteListItems });
|
||||
};
|
||||
|
||||
export default function NotesPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-screen flex-col">
|
||||
<header className="flex items-center justify-between bg-slate-800 p-4 text-white">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<Link to="">Notes</Link>
|
||||
</h1>
|
||||
<p>{user.email}</p>
|
||||
<Form action="/logout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-slate-600 px-4 py-2 text-blue-100 hover:bg-blue-500 active:bg-blue-600"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</header>
|
||||
|
||||
<main className="flex h-full bg-white">
|
||||
<div className="h-full w-80 border-r bg-gray-50">
|
||||
<Link to="new" className="block p-4 text-xl text-blue-500">
|
||||
+ New Note
|
||||
</Link>
|
||||
|
||||
<hr />
|
||||
|
||||
{data.noteListItems.length === 0 ? (
|
||||
<p className="p-4">No notes yet</p>
|
||||
) : (
|
||||
<ol>
|
||||
{data.noteListItems.map((note) => (
|
||||
<li key={note.id}>
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`block border-b p-4 text-xl ${isActive ? "bg-white" : ""}`
|
||||
}
|
||||
to={note.id}
|
||||
>
|
||||
📝 {note.title}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/session.server.ts
vendored
Normal file
97
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/session.server.ts
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
import { createCookieSessionStorage, redirect } from "@remix-run/node";
|
||||
import invariant from "tiny-invariant";
|
||||
|
||||
import type { User } from "~/models/user.server";
|
||||
import { getUserById } from "~/models/user.server";
|
||||
|
||||
invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set");
|
||||
|
||||
export const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__session",
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secrets: [process.env.SESSION_SECRET],
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
});
|
||||
|
||||
const USER_SESSION_KEY = "userId";
|
||||
|
||||
export async function getSession(request: Request) {
|
||||
const cookie = request.headers.get("Cookie");
|
||||
return sessionStorage.getSession(cookie);
|
||||
}
|
||||
|
||||
export async function getUserId(
|
||||
request: Request,
|
||||
): Promise<User["id"] | undefined> {
|
||||
const session = await getSession(request);
|
||||
const userId = session.get(USER_SESSION_KEY);
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function getUser(request: Request) {
|
||||
const userId = await getUserId(request);
|
||||
if (userId === undefined) return null;
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (user) return user;
|
||||
|
||||
throw await logout(request);
|
||||
}
|
||||
|
||||
export async function requireUserId(
|
||||
request: Request,
|
||||
redirectTo: string = new URL(request.url).pathname,
|
||||
) {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
|
||||
throw redirect(`/login?${searchParams}`);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function requireUser(request: Request) {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (user) return user;
|
||||
|
||||
throw await logout(request);
|
||||
}
|
||||
|
||||
export async function createUserSession({
|
||||
request,
|
||||
userId,
|
||||
remember,
|
||||
redirectTo,
|
||||
}: {
|
||||
request: Request;
|
||||
userId: string;
|
||||
remember: boolean;
|
||||
redirectTo: string;
|
||||
}) {
|
||||
const session = await getSession(request);
|
||||
session.set(USER_SESSION_KEY, userId);
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await sessionStorage.commitSession(session, {
|
||||
maxAge: remember
|
||||
? 60 * 60 * 24 * 7 // 7 days
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(request: Request) {
|
||||
const session = await getSession(request);
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await sessionStorage.destroySession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
13
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/singleton.server.ts
vendored
Normal file
13
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/singleton.server.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// Since the dev server re-requires the bundle, do some shenanigans to make
|
||||
// certain things persist across that 😆
|
||||
// Borrowed/modified from https://github.com/jenseng/abuse-the-platform/blob/2993a7e846c95ace693ce61626fa072174c8d9c7/app/utils/singleton.ts
|
||||
|
||||
export const singleton = <Value>(
|
||||
name: string,
|
||||
valueFactory: () => Value,
|
||||
): Value => {
|
||||
const g = global as unknown as { __singletons: Record<string, unknown> };
|
||||
g.__singletons ??= {};
|
||||
g.__singletons[name] ??= valueFactory();
|
||||
return g.__singletons[name] as Value;
|
||||
};
|
||||
3
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/tailwind.css
vendored
Normal file
3
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/tailwind.css
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
76
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/utils.ts
vendored
Normal file
76
packages/shadcn/test/fixtures/frameworks/remix-indie-stack/app/utils.ts
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useMatches } from "@remix-run/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { User } from "~/models/user.server";
|
||||
|
||||
const DEFAULT_REDIRECT = "/";
|
||||
|
||||
/**
|
||||
* This should be used any time the redirect path is user-provided
|
||||
* (Like the query string on our login/signup pages). This avoids
|
||||
* open-redirect vulnerabilities.
|
||||
* @param {string} to The redirect destination
|
||||
* @param {string} defaultRedirect The redirect to use if the to is unsafe.
|
||||
*/
|
||||
export function safeRedirect(
|
||||
to: FormDataEntryValue | string | null | undefined,
|
||||
defaultRedirect: string = DEFAULT_REDIRECT,
|
||||
) {
|
||||
if (!to || typeof to !== "string") {
|
||||
return defaultRedirect;
|
||||
}
|
||||
|
||||
if (!to.startsWith("/") || to.startsWith("//")) {
|
||||
return defaultRedirect;
|
||||
}
|
||||
|
||||
return to;
|
||||
}
|
||||
|
||||
/**
|
||||
* This base hook is used in other hooks to quickly search for specific data
|
||||
* across all loader data using useMatches.
|
||||
* @param {string} id The route id
|
||||
* @returns {JSON|undefined} The router data or undefined if not found
|
||||
*/
|
||||
export function useMatchesData(
|
||||
id: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const matchingRoutes = useMatches();
|
||||
const route = useMemo(
|
||||
() => matchingRoutes.find((route) => route.id === id),
|
||||
[matchingRoutes, id],
|
||||
);
|
||||
return route?.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isUser(user: unknown): user is User {
|
||||
return (
|
||||
user != null &&
|
||||
typeof user === "object" &&
|
||||
"email" in user &&
|
||||
typeof user.email === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function useOptionalUser(): User | undefined {
|
||||
const data = useMatchesData("root");
|
||||
if (!data || !isUser(data.user)) {
|
||||
return undefined;
|
||||
}
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export function useUser(): User {
|
||||
const maybeUser = useOptionalUser();
|
||||
if (!maybeUser) {
|
||||
throw new Error(
|
||||
"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead.",
|
||||
);
|
||||
}
|
||||
return maybeUser;
|
||||
}
|
||||
|
||||
export function validateEmail(email: unknown): email is string {
|
||||
return typeof email === "string" && email.length > 3 && email.includes("@");
|
||||
}
|
||||
Reference in New Issue
Block a user