diff --git a/.gitignore b/.gitignore index b512c09..e43b0f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -node_modules \ No newline at end of file +.DS_Store diff --git a/seed/remix-trellix/.dockerignore b/seed/remix-trellix/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/seed/remix-trellix/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/seed/remix-trellix/.eslintrc.cjs b/seed/remix-trellix/.eslintrc.cjs new file mode 100644 index 0000000..7505daf --- /dev/null +++ b/seed/remix-trellix/.eslintrc.cjs @@ -0,0 +1,12 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], + "overrides": [ + { + "rules": { + "no-unused-vars": "warn", + "@typescript-eslint/no-unused-vars": "warn" + } + } + ] +}; diff --git a/seed/remix-trellix/.gitignore b/seed/remix-trellix/.gitignore new file mode 100644 index 0000000..d09d672 --- /dev/null +++ b/seed/remix-trellix/.gitignore @@ -0,0 +1,7 @@ +node_modules + +/.cache +/build +/public/build +.env +/prisma/dev.db diff --git a/seed/remix-trellix/.snaplet/config.json b/seed/remix-trellix/.snaplet/config.json new file mode 100644 index 0000000..59a0945 --- /dev/null +++ b/seed/remix-trellix/.snaplet/config.json @@ -0,0 +1,3 @@ +{ + "targetDatabaseUrl": "postgresql://postgres@localhost:5432/trellix" +} \ No newline at end of file diff --git a/seed/remix-trellix/Dockerfile b/seed/remix-trellix/Dockerfile new file mode 100644 index 0000000..7aae29c --- /dev/null +++ b/seed/remix-trellix/Dockerfile @@ -0,0 +1,57 @@ +# base node image +FROM node:18-bullseye-slim as base + +# Install openssl for Prisma +RUN apt-get update && apt-get install -y openssl + +ENV NODE_ENV production + +# Install all node_modules, including dev dependencies +FROM base as deps + +RUN mkdir /app +WORKDIR /app + +ADD package.json package-lock.json ./ +RUN npm install --production=false + +# Setup production node_modules +FROM base as production-deps + +RUN mkdir /app +WORKDIR /app + +COPY --from=deps /app/node_modules /app/node_modules +ADD package.json package-lock.json ./ +RUN npm prune --production + +# Build the app +FROM base as build + +RUN mkdir /app +WORKDIR /app + +COPY --from=deps /app/node_modules /app/node_modules + +ADD prisma . +RUN npx prisma generate + +ADD . . +RUN npm run build + +# Finally, build the production image with minimal footprint +FROM base + +ENV NODE_ENV production + +RUN mkdir /app +WORKDIR /app + +COPY --from=production-deps /app/node_modules /app/node_modules +COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma +COPY --from=build /app/build /app/build +COPY --from=build /app/public /app/public +ADD . . + +# CMD ["npm", "run", "start"] +ENTRYPOINT [ "./start.sh" ] diff --git a/seed/remix-trellix/README.md b/seed/remix-trellix/README.md new file mode 100644 index 0000000..a5c5a03 --- /dev/null +++ b/seed/remix-trellix/README.md @@ -0,0 +1,5 @@ +```sh +npm i +npx prisma migrate dev +npm run dev +``` diff --git a/seed/remix-trellix/app/auth/auth.ts b/seed/remix-trellix/app/auth/auth.ts new file mode 100644 index 0000000..7357916 --- /dev/null +++ b/seed/remix-trellix/app/auth/auth.ts @@ -0,0 +1,66 @@ +import { type DataFunctionArgs, createCookie, redirect } from "@remix-run/node"; + +let secret = process.env.COOKIE_SECRET || "default"; +if (secret === "default") { + console.warn( + "🚨 No COOKIE_SECRET environment variable set, using default. The app is insecure in production.", + ); + secret = "default-secret"; +} + +let cookie = createCookie("auth", { + secrets: [secret], + // 30 days + maxAge: 30 * 24 * 60 * 60, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", +}); + +export async function getAuthFromRequest( + request: Request, +): Promise { + let userId = await cookie.parse(request.headers.get("Cookie")); + return userId ?? null; +} + +export async function setAuthOnResponse( + response: Response, + userId: string, +): Promise { + let header = await cookie.serialize(userId); + response.headers.append("Set-Cookie", header); + return response; +} + +export async function requireAuthCookie(request: Request) { + let userId = await getAuthFromRequest(request); + if (!userId) { + throw redirect("/login", { + headers: { + "Set-Cookie": await cookie.serialize("", { + maxAge: 0, + }), + }, + }); + } + return userId; +} + +export async function redirectIfLoggedInLoader({ request }: DataFunctionArgs) { + let userId = await getAuthFromRequest(request); + if (userId) { + throw redirect("/home"); + } + return null; +} + +export async function redirectWithClearedCookie(): Promise { + return redirect("/", { + headers: { + "Set-Cookie": await cookie.serialize(null, { + expires: new Date(0), + }), + }, + }); +} diff --git a/seed/remix-trellix/app/components/button.tsx b/seed/remix-trellix/app/components/button.tsx new file mode 100644 index 0000000..1b31834 --- /dev/null +++ b/seed/remix-trellix/app/components/button.tsx @@ -0,0 +1,14 @@ +import { forwardRef } from "react"; + +export let Button = forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>((props, ref) => { + return ( + + + ) : ( + + +
+ + Log in + + + )} + + + +
+ +
+ + + + + + + + ); +} + +function IconLink({ + icon, + href, + label, +}: { + icon: string; + href: string; + label: string; +}) { + return ( + + + {label} + + ); +} diff --git a/seed/remix-trellix/app/routes/_index.tsx b/seed/remix-trellix/app/routes/_index.tsx new file mode 100644 index 0000000..601fabb --- /dev/null +++ b/seed/remix-trellix/app/routes/_index.tsx @@ -0,0 +1,50 @@ +import type { MetaFunction } from "@remix-run/node"; +import { Link } from "@remix-run/react"; + +export const meta: MetaFunction = () => { + return [{ title: "Trellix, a Remix Demo" }]; +}; + +export default function Index() { + return ( +
+ +
+

+ This is a demo app to show off the features of Remix and teach them + through some videos we've published on{" "} + + YouTube + + . +

+

+ It's a recreation of the popular drag and drop interface in{" "} + + Trello + {" "} + and other similar apps. +

+

If you want to play around, click sign up!

+
+
+ + Sign up + +
+ + Login + +
+
+ ); +} diff --git a/seed/remix-trellix/app/routes/board.$id/board.tsx b/seed/remix-trellix/app/routes/board.$id/board.tsx new file mode 100644 index 0000000..9fcf843 --- /dev/null +++ b/seed/remix-trellix/app/routes/board.$id/board.tsx @@ -0,0 +1,137 @@ +import { useRef } from "react"; +import invariant from "tiny-invariant"; +import { useFetchers, useLoaderData } from "@remix-run/react"; + +import { type loader } from "./route"; +import { INTENTS, type RenderedItem } from "./types"; +import { Column } from "./column"; +import { NewColumn } from "./new-column"; +import { EditableText } from "./components"; + +export function Board() { + let { board } = useLoaderData(); + + let itemsById = new Map(board.items.map((item) => [item.id, item])); + + let pendingItems = usePendingItems(); + + // merge pending items and existing items + for (let pendingItem of pendingItems) { + let item = itemsById.get(pendingItem.id); + let merged = item + ? { ...item, ...pendingItem } + : { ...pendingItem, boardId: board.id }; + itemsById.set(pendingItem.id, merged); + } + + // merge pending and existing columns + let optAddingColumns = usePendingColumns(); + type Column = + | (typeof board.columns)[number] + | (typeof optAddingColumns)[number]; + type ColumnWithItems = Column & { items: typeof board.items }; + let columns = new Map(); + for (let column of [...board.columns, ...optAddingColumns]) { + columns.set(column.id, { ...column, items: [] }); + } + + // add items to their columns + for (let item of itemsById.values()) { + let columnId = item.columnId; + let column = columns.get(columnId); + invariant(column, "missing column"); + column.items.push(item); + } + + // scroll right when new columns are added + let scrollContainerRef = useRef(null); + function scrollRight() { + invariant(scrollContainerRef.current, "no scroll container"); + scrollContainerRef.current.scrollLeft = + scrollContainerRef.current.scrollWidth; + } + + return ( +
+

+ + + + +

+ +
+ {[...columns.values()].map((col) => { + return ( + + ); + })} + + + + {/* trolling you to add some extra margin to the right of the container with a whole dang div */} +
+
+
+ ); +} + +// These are the inflight columns that are being created, instead of managing +// state ourselves, we just ask Remix for the state +function usePendingColumns() { + type CreateColumnFetcher = ReturnType[number] & { + formData: FormData; + }; + + return useFetchers() + .filter((fetcher): fetcher is CreateColumnFetcher => { + return fetcher.formData?.get("intent") === INTENTS.createColumn; + }) + .map((fetcher) => { + let name = String(fetcher.formData.get("name")); + let id = String(fetcher.formData.get("id")); + return { name, id }; + }); +} + +// These are the inflight items that are being created or moved, instead of +// managing state ourselves, we just ask Remix for the state +function usePendingItems() { + type PendingItem = ReturnType[number] & { + formData: FormData; + }; + return useFetchers() + .filter((fetcher): fetcher is PendingItem => { + if (!fetcher.formData) return false; + let intent = fetcher.formData.get("intent"); + return intent === INTENTS.createItem || intent === INTENTS.moveItem; + }) + .map((fetcher) => { + let columnId = String(fetcher.formData.get("columnId")); + let title = String(fetcher.formData.get("title")); + let id = String(fetcher.formData.get("id")); + let order = Number(fetcher.formData.get("order")); + let item: RenderedItem = { title, id, order, columnId, content: null }; + return item; + }); +} diff --git a/seed/remix-trellix/app/routes/board.$id/card.tsx b/seed/remix-trellix/app/routes/board.$id/card.tsx new file mode 100644 index 0000000..0625cb9 --- /dev/null +++ b/seed/remix-trellix/app/routes/board.$id/card.tsx @@ -0,0 +1,116 @@ +import invariant from "tiny-invariant"; +import { useFetcher, useSubmit } from "@remix-run/react"; +import { useState } from "react"; + +import { Icon } from "~/icons/icons"; + +import { ItemMutation, INTENTS, CONTENT_TYPES } from "./types"; + +interface CardProps { + title: string; + content: string | null; + id: string; + columnId: string; + order: number; + nextOrder: number; + previousOrder: number; +} + +export function Card({ + title, + content, + id, + columnId, + order, + nextOrder, + previousOrder, +}: CardProps) { + let submit = useSubmit(); + let deleteFetcher = useFetcher(); + + let [acceptDrop, setAcceptDrop] = useState<"none" | "top" | "bottom">("none"); + + return deleteFetcher.state !== "idle" ? null : ( +
  • { + if (event.dataTransfer.types.includes(CONTENT_TYPES.card)) { + event.preventDefault(); + event.stopPropagation(); + let rect = event.currentTarget.getBoundingClientRect(); + let midpoint = (rect.top + rect.bottom) / 2; + setAcceptDrop(event.clientY <= midpoint ? "top" : "bottom"); + } + }} + onDragLeave={() => { + setAcceptDrop("none"); + }} + onDrop={(event) => { + event.stopPropagation(); + + let transfer = JSON.parse( + event.dataTransfer.getData(CONTENT_TYPES.card), + ); + invariant(transfer.id, "missing cardId"); + invariant(transfer.title, "missing title"); + + let droppedOrder = acceptDrop === "top" ? previousOrder : nextOrder; + let moveOrder = (droppedOrder + order) / 2; + + let mutation: ItemMutation = { + order: moveOrder, + columnId: columnId, + id: transfer.id, + title: transfer.title, + }; + + submit( + { ...mutation, intent: INTENTS.moveItem }, + { + method: "post", + navigate: false, + fetcherKey: `card:${transfer.id}`, + }, + ); + + setAcceptDrop("none"); + }} + className={ + "border-t-2 border-b-2 -mb-[2px] last:mb-0 cursor-grab active:cursor-grabbing px-2 py-1 " + + (acceptDrop === "top" + ? "border-t-brand-red border-b-transparent" + : acceptDrop === "bottom" + ? "border-b-brand-red border-t-transparent" + : "border-t-transparent border-b-transparent") + } + > +
    { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData( + CONTENT_TYPES.card, + JSON.stringify({ id, title }), + ); + }} + > +

    {title}

    +
    {content || <> }
    + + + + + +
    +
  • + ); +} diff --git a/seed/remix-trellix/app/routes/board.$id/column.tsx b/seed/remix-trellix/app/routes/board.$id/column.tsx new file mode 100644 index 0000000..faf0eb0 --- /dev/null +++ b/seed/remix-trellix/app/routes/board.$id/column.tsx @@ -0,0 +1,139 @@ +import { useState, useRef } from "react"; +import { useSubmit } from "@remix-run/react"; +import invariant from "tiny-invariant"; + +import { Icon } from "~/icons/icons"; + +import { + ItemMutation, + INTENTS, + CONTENT_TYPES, + type RenderedItem, +} from "./types"; +import { NewCard } from "./new-card"; +import { flushSync } from "react-dom"; +import { Card } from "./card"; +import { EditableText } from "./components"; + +interface ColumnProps { + name: string; + columnId: string; + items: RenderedItem[]; +} + +export function Column({ name, columnId, items }: ColumnProps) { + let submit = useSubmit(); + + let [acceptDrop, setAcceptDrop] = useState(false); + let [edit, setEdit] = useState(false); + let listRef = useRef(null); + + function scrollList() { + invariant(listRef.current); + listRef.current.scrollTop = listRef.current.scrollHeight; + } + + return ( +
    { + if ( + items.length === 0 && + event.dataTransfer.types.includes(CONTENT_TYPES.card) + ) { + event.preventDefault(); + setAcceptDrop(true); + } + }} + onDragLeave={() => { + setAcceptDrop(false); + }} + onDrop={(event) => { + let transfer = JSON.parse( + event.dataTransfer.getData(CONTENT_TYPES.card), + ); + invariant(transfer.id, "missing transfer.id"); + invariant(transfer.title, "missing transfer.title"); + + let mutation: ItemMutation = { + order: 1, + columnId: columnId, + id: transfer.id, + title: transfer.title, + }; + + submit( + { ...mutation, intent: INTENTS.moveItem }, + { + method: "post", + navigate: false, + // use the same fetcher instance for any mutations on this card so + // that interruptions cancel the earlier request and revalidation + fetcherKey: `card:${transfer.id}`, + }, + ); + + setAcceptDrop(false); + }} + > +
    + + + + +
    + +
      + {items + .sort((a, b) => a.order - b.order) + .map((item, index, items) => ( + + ))} +
    + {edit ? ( + scrollList()} + onComplete={() => setEdit(false)} + /> + ) : ( +
    + +
    + )} +
    + ); +} diff --git a/seed/remix-trellix/app/routes/board.$id/components.tsx b/seed/remix-trellix/app/routes/board.$id/components.tsx new file mode 100644 index 0000000..f13bd9b --- /dev/null +++ b/seed/remix-trellix/app/routes/board.$id/components.tsx @@ -0,0 +1,119 @@ +import { useFetcher } from "@remix-run/react"; +import { forwardRef, useRef, useState } from "react"; +import { flushSync } from "react-dom"; + +export let SaveButton = forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>((props, ref) => { + return ( + + ); +} diff --git a/seed/remix-trellix/app/routes/board.$id/new-card.tsx b/seed/remix-trellix/app/routes/board.$id/new-card.tsx new file mode 100644 index 0000000..ce8c620 --- /dev/null +++ b/seed/remix-trellix/app/routes/board.$id/new-card.tsx @@ -0,0 +1,91 @@ +import { useRef } from "react"; +import invariant from "tiny-invariant"; +import { Form, useSubmit } from "@remix-run/react"; + +import { INTENTS, ItemMutationFields } from "./types"; +import { SaveButton, CancelButton } from "./components"; + +export function NewCard({ + columnId, + nextOrder, + onComplete, + onAddCard, +}: { + columnId: string; + nextOrder: number; + onComplete: () => void; + onAddCard: () => void; +}) { + let textAreaRef = useRef(null); + let buttonRef = useRef(null); + let submit = useSubmit(); + + return ( +
    { + event.preventDefault(); + + let formData = new FormData(event.currentTarget); + let id = crypto.randomUUID(); + formData.set(ItemMutationFields.id.name, id); + + submit(formData, { + method: "post", + fetcherKey: `card:${id}`, + navigate: false, + unstable_flushSync: true, + }); + + invariant(textAreaRef.current); + textAreaRef.current.value = ""; + onAddCard(); + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + onComplete(); + } + }} + > + + + + +