├── .prettierrc ├── .env.example ├── public └── favicon.ico ├── .eslintrc ├── .gitignore ├── remix.env.d.ts ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20220824032545_initial │ │ └── migration.sql └── schema.prisma ├── tailwind.config.js ├── app ├── models.server.ts ├── prisma.server.ts ├── models │ ├── content.server.ts │ ├── user.server.ts │ ├── fields.server.ts │ ├── projects.server.ts │ └── models.server.ts ├── app.config.ts ├── entry.client.tsx ├── utils.ts ├── session.server.ts ├── routes │ ├── dashboard._menu.$projectId.models.index.tsx │ ├── logout.ts │ ├── _layout.$.tsx │ ├── _layout.tsx │ ├── _layout.index.tsx │ ├── dashboard_.$.tsx │ ├── dashboard._menu.$projectId.index.tsx │ ├── dashboard_.index.tsx │ ├── dashboard._menu.$projectId.$.tsx │ ├── dashboard_.new.tsx │ ├── _layout.login.tsx │ ├── dashboard._menu.$projectId.models.tsx │ ├── dashboard._menu.$projectId.tsx │ ├── dashboard._menu.$projectId.models.$modelId.$.tsx │ ├── dashboard._menu.$projectId.models.$modelId.tsx │ ├── dashboard_.tsx │ └── dashboard._menu.$projectId.models.new.tsx ├── entry.server.tsx ├── auth.server.ts ├── sprites.svg ├── root.tsx └── components │ └── dashboard.tsx ├── remix.config.js ├── styles.css ├── sprites ├── add.svg ├── back.svg ├── menu.svg └── close.svg ├── scripts └── sprites.ts ├── tsconfig.json ├── README.md └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./dev.db" 2 | ENCRYPTION_SECRET="super-duper-secret" 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/remix-cms/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | _generated 8 | *.db 9 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["app/**/*.tsx"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /app/models.server.ts: -------------------------------------------------------------------------------- 1 | export * from "./models/content.server"; 2 | export * from "./models/fields.server"; 3 | export * from "./models/models.server"; 4 | export * from "./models/projects.server"; 5 | export * from "./models/user.server"; 6 | -------------------------------------------------------------------------------- /app/prisma.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prismaClient: PrismaClient; 5 | } 6 | 7 | export const prisma = (global.prismaClient = 8 | global.prismaClient || new PrismaClient()); 9 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | const { flatRoutes } = require("remix-flat-routes"); 2 | 3 | /** @type {import('@remix-run/dev').AppConfig} */ 4 | module.exports = { 5 | ignoredRouteFiles: ["**/*"], 6 | routes: (defineRoutes) => flatRoutes("routes", defineRoutes), 7 | }; 8 | -------------------------------------------------------------------------------- /app/models/content.server.ts: -------------------------------------------------------------------------------- 1 | export async function countContent({ 2 | userId, 3 | projectId, 4 | }: { 5 | userId: string; 6 | projectId: string; 7 | }): Promise { 8 | await new Promise((resolve) => setTimeout(resolve, 200)); 9 | return 0; 10 | } 11 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .hide .hideable { 6 | display: none; 7 | } 8 | 9 | .hide-inverse .hideable-inverse { 10 | display: none; 11 | } 12 | 13 | .vtbl { 14 | writing-mode: vertical-rl; 15 | text-orientation: mixed; 16 | } 17 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export const appName = "Remix CMS"; 2 | 3 | export type DataTypes = 4 | | "bool" 5 | | "int" 6 | | "float" 7 | | "string" 8 | | "json" 9 | | "reference"; 10 | export const dataTypes = [ 11 | "bool", 12 | "int", 13 | "float", 14 | "string", 15 | "json", 16 | "reference", 17 | ] as const; 18 | -------------------------------------------------------------------------------- /sprites/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RemixBrowser } from "@remix-run/react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | React.startTransition(() => { 6 | hydrateRoot(document, ); 7 | }); 8 | 9 | if (typeof document.createElement("dialog").showModal !== "function") { 10 | import("a11y-dialog"); 11 | } 12 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | export function invariant(value: boolean, message?: string): asserts value; 2 | export function invariant( 3 | value: T | null | undefined, 4 | message?: string 5 | ): asserts value is T; 6 | export function invariant(value: any, message: string = "invariant failed") { 7 | if (value === false || value === null || typeof value === "undefined") { 8 | throw new Error(message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /sprites/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /sprites/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /scripts/sprites.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | // @ts-expect-error 4 | import svgstore from "svgstore"; 5 | 6 | const sprites = svgstore(); 7 | 8 | fs.readdirSync("./sprites").forEach(function (file) { 9 | if (file.endsWith(".svg")) { 10 | sprites.add( 11 | file.replace(".svg", ""), 12 | fs.readFileSync(path.join("sprites", file), "utf8") 13 | ); 14 | } 15 | console.log(file); 16 | }); 17 | 18 | fs.writeFileSync("app/sprites.svg", sprites); 19 | -------------------------------------------------------------------------------- /sprites/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from "@remix-run/node"; 2 | 3 | if (!process.env.ENCRYPTION_SECRET) { 4 | throw new Error("ENCRYPTION_SECRET environment variable is not set"); 5 | } 6 | 7 | export const USER_KEY = "user"; 8 | 9 | export const sessionStorage = createCookieSessionStorage({ 10 | cookie: { 11 | name: "_session", 12 | sameSite: "lax", 13 | path: "/", 14 | httpOnly: true, 15 | secrets: [process.env.ENCRYPTION_SECRET], 16 | secure: process.env.NODE_ENV === "production", 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /app/routes/dashboard._menu.$projectId.models.index.tsx: -------------------------------------------------------------------------------- 1 | export default function DashboardIndex() { 2 | return ( 3 |
4 |
5 |
6 |

Select or create a model to get started.

7 |
8 | Models define the structure of your data and API. 9 |
10 |
11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/routes/logout.ts: -------------------------------------------------------------------------------- 1 | import type { ActionArgs } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { sessionStorage } from "~/session.server"; 5 | 6 | export function loader() { 7 | return redirect("/login"); 8 | } 9 | 10 | export async function action({ request }: ActionArgs) { 11 | const session = await sessionStorage.getSession( 12 | request.headers.get("Cookie") 13 | ); 14 | 15 | return redirect("/login", { 16 | headers: { 17 | "Set-Cookie": await sessionStorage.destroySession(session), 18 | }, 19 | }); 20 | } 21 | 22 | export default () => null; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "module": "ES2020", 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "target": "ES2019", 12 | "strict": true, 13 | "allowJs": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "~/*": ["./app/*"] 18 | }, 19 | 20 | // Remix takes care of building everything in `remix build`. 21 | "noEmit": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/routes/_layout.$.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import type { ShouldReloadFunction } from "@remix-run/react"; 4 | import { Link, useCatch } from "@remix-run/react"; 5 | 6 | export async function loader({ request, params }: LoaderArgs) { 7 | throw json(null, 404); 8 | } 9 | 10 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { 11 | return false; 12 | }; 13 | 14 | export function CatchBoundary() { 15 | const { status } = useCatch(); 16 | return ( 17 |
18 |

{status}

19 |
20 | Go home 21 |
22 |
23 | ); 24 | } 25 | 26 | export default function GlobalCatchAll() { 27 | return null; 28 | } 29 | -------------------------------------------------------------------------------- /app/routes/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet } from "@remix-run/react"; 2 | 3 | import * as appConfig from "~/app.config"; 4 | 5 | export default function Layout() { 6 | return ( 7 |
8 |
9 |
10 |
11 |
12 |
13 |

14 | {appConfig.appName} 15 |

16 |
17 |
18 | 19 |
20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/routes/_layout.index.tsx: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/node"; 2 | import type { LoaderArgs } from "@remix-run/node"; 3 | import { Link, useLoaderData } from "@remix-run/react"; 4 | 5 | import { authenticate } from "~/auth.server"; 6 | 7 | export async function loader({ request }: LoaderArgs) { 8 | const user = await authenticate(request, {}); 9 | 10 | return json({ 11 | authenticated: !!user, 12 | }); 13 | } 14 | 15 | export default function Index() { 16 | const { authenticated } = useLoaderData(); 17 | 18 | return ( 19 |
20 |

Hello, World!

21 |
22 | {authenticated ? ( 23 | Go to the Dashboard 24 | ) : ( 25 | Login 26 | )} 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/models/user.server.ts: -------------------------------------------------------------------------------- 1 | import { compare, hash } from "bcrypt"; 2 | 3 | import type { AuthenticatedUser } from "~/auth.server"; 4 | import { prisma } from "~/prisma.server"; 5 | 6 | export async function findOrCreateUser({ 7 | username, 8 | password, 9 | }: { 10 | username: string; 11 | password: string; 12 | }): Promise { 13 | const hashedPassword = await hash(password, 10); 14 | 15 | const foundUser = await prisma.user.findUnique({ 16 | where: { 17 | username, 18 | }, 19 | select: { 20 | id: true, 21 | hashedPassword: true, 22 | }, 23 | }); 24 | 25 | if (foundUser) { 26 | if (await compare(password, foundUser.hashedPassword)) { 27 | return { 28 | id: foundUser.id, 29 | }; 30 | } 31 | return undefined; 32 | } 33 | 34 | const newUser = await prisma.user.create({ 35 | data: { 36 | username, 37 | hashedPassword, 38 | }, 39 | select: { 40 | id: true, 41 | }, 42 | }); 43 | 44 | return { 45 | id: newUser.id, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /app/models/fields.server.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "~/prisma.server"; 2 | 3 | export async function getField({ 4 | fieldId, 5 | userId, 6 | }: { 7 | fieldId: string; 8 | userId: string; 9 | }) { 10 | const field = await prisma.modelField.findFirst({ 11 | where: { 12 | id: fieldId, 13 | model: { 14 | project: { 15 | users: { 16 | some: { 17 | id: userId, 18 | }, 19 | }, 20 | }, 21 | }, 22 | }, 23 | select: { 24 | id: true, 25 | createdAt: true, 26 | array: true, 27 | type: true, 28 | required: true, 29 | name: true, 30 | typeReference: { 31 | select: { 32 | id: true, 33 | name: true, 34 | }, 35 | }, 36 | }, 37 | }); 38 | 39 | if (!field) return undefined; 40 | 41 | return { 42 | id: field.id, 43 | createdAt: field.createdAt, 44 | array: field.array, 45 | type: field.type, 46 | required: field.required, 47 | name: field.name, 48 | typeReferenceId: field.typeReference?.id, 49 | typeReferenceName: field.typeReference?.name, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | This starts your app in development mode, rebuilding assets on file changes. 14 | 15 | ## Deployment 16 | 17 | First, build your app for production: 18 | 19 | ```sh 20 | npm run build 21 | ``` 22 | 23 | Then run the app in production mode: 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | Now you'll need to pick a host to deploy it to. 30 | 31 | ### DIY 32 | 33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready. 34 | 35 | Make sure to deploy the output of `remix build` 36 | 37 | - `build/` 38 | - `public/build/` 39 | 40 | ### Using a Template 41 | 42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server. 43 | 44 | ```sh 45 | cd .. 46 | # create a new project, and pick a pre-configured host 47 | npx create-remix@latest 48 | cd my-new-remix-app 49 | # remove the new project's app (not the old one!) 50 | rm -rf app 51 | # copy your app over 52 | cp -R ../my-old-remix-app/app app 53 | ``` 54 | -------------------------------------------------------------------------------- /app/models/projects.server.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "~/prisma.server"; 2 | 3 | export async function createProject({ 4 | userId, 5 | name, 6 | description, 7 | }: { 8 | userId: string; 9 | name: string; 10 | description?: string; 11 | }) { 12 | const project = await prisma.project.create({ 13 | data: { 14 | name, 15 | description, 16 | users: { 17 | connect: [{ id: userId }], 18 | }, 19 | }, 20 | }); 21 | 22 | return { id: project.id }; 23 | } 24 | 25 | export async function projectExists({ 26 | userId, 27 | projectId, 28 | }: { 29 | userId: string; 30 | projectId: string; 31 | }) { 32 | const project = await prisma.project.findFirst({ 33 | where: { 34 | id: projectId, 35 | users: { 36 | some: { 37 | id: userId, 38 | }, 39 | }, 40 | }, 41 | select: { 42 | id: true, 43 | }, 44 | }); 45 | 46 | return !!project; 47 | } 48 | 49 | export async function getProjects({ userId }: { userId: string }) { 50 | const projects = await prisma.project.findMany({ 51 | where: { 52 | users: { 53 | some: { 54 | id: userId, 55 | }, 56 | }, 57 | }, 58 | select: { 59 | id: true, 60 | name: true, 61 | description: true, 62 | }, 63 | }); 64 | 65 | return projects.map(({ id, name, description }) => ({ 66 | id, 67 | name, 68 | description: description || undefined, 69 | })); 70 | } 71 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import type { EntryContext } from "@remix-run/node"; 3 | import { Response } from "@remix-run/node"; 4 | import { RemixServer } from "@remix-run/react"; 5 | import { renderToPipeableStream } from "react-dom/server"; 6 | 7 | const ABORT_DELAY = 5000; 8 | 9 | export default function handleRequest( 10 | request: Request, 11 | responseStatusCode: number, 12 | responseHeaders: Headers, 13 | remixContext: EntryContext 14 | ) { 15 | return new Promise((resolve, reject) => { 16 | let didError = false; 17 | 18 | const { pipe, abort } = renderToPipeableStream( 19 | , 24 | { 25 | onShellReady: () => { 26 | const body = new PassThrough(); 27 | 28 | responseHeaders.set("Content-Type", "text/html"); 29 | 30 | resolve( 31 | new Response(body, { 32 | headers: responseHeaders, 33 | status: didError ? 500 : responseStatusCode, 34 | }) 35 | ); 36 | 37 | pipe(body); 38 | }, 39 | onShellError: (err) => { 40 | reject(err); 41 | }, 42 | onError: (error) => { 43 | didError = true; 44 | 45 | console.error(error); 46 | }, 47 | } 48 | ); 49 | 50 | setTimeout(abort, ABORT_DELAY); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /app/routes/dashboard_.$.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import type { ShouldReloadFunction } from "@remix-run/react"; 4 | import { Link, useCatch } from "@remix-run/react"; 5 | 6 | import { authenticate } from "~/auth.server"; 7 | 8 | export async function loader({ request }: LoaderArgs) { 9 | await authenticate(request); 10 | return json(null, 404); 11 | } 12 | 13 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { 14 | if (submission) { 15 | return true; 16 | } 17 | 18 | return false; 19 | }; 20 | 21 | export function CatchBoundary() { 22 | const { status } = useCatch(); 23 | 24 | return ( 25 |
26 |
27 |
28 |

{status}

29 |
30 | Go to the dashboard 31 |
32 |
33 |
34 |
35 | ); 36 | } 37 | 38 | export default function DashboardIndex() { 39 | return ( 40 |
41 |
42 |
43 |

404

44 |
45 | Go to the dashboard 46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/auth.server.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { sessionStorage, USER_KEY } from "~/session.server"; 5 | 6 | export interface AuthenticatedUser { 7 | id: string; 8 | } 9 | 10 | function getUser(session: Session): AuthenticatedUser | undefined { 11 | return session.get(USER_KEY) || undefined; 12 | } 13 | 14 | export function setUser(session: Session, user: AuthenticatedUser) { 15 | session.set(USER_KEY, user); 16 | } 17 | 18 | export async function authenticate( 19 | request: Request, 20 | options?: never 21 | ): Promise; 22 | export async function authenticate( 23 | request: Request, 24 | options: { successRedirect: string } 25 | ): Promise; 26 | export async function authenticate( 27 | request: Request, 28 | options: { failureRedirect: string } 29 | ): Promise; 30 | export async function authenticate( 31 | request: Request, 32 | options: {} 33 | ): Promise; 34 | export async function authenticate( 35 | request: Request, 36 | { 37 | failureRedirect, 38 | successRedirect, 39 | }: { failureRedirect?: string; successRedirect?: string } = { 40 | failureRedirect: "/login", 41 | } 42 | ): Promise { 43 | const session = await sessionStorage.getSession( 44 | request.headers.get("Cookie") 45 | ); 46 | 47 | const user = getUser(session); 48 | 49 | if (user && successRedirect) { 50 | throw redirect(successRedirect); 51 | } 52 | if (!user && failureRedirect) { 53 | throw redirect(failureRedirect); 54 | } 55 | 56 | return user || null; 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "build:css": "tailwindcss -i styles.css -o app/_generated/styles.css", 6 | "build:remix": "remix build", 7 | "build": "npm run build:css && npm run build:remix", 8 | "dev:css": "tailwindcss -i styles.css -o app/_generated/styles.css --watch", 9 | "dev:remix": "remix dev", 10 | "dev": "npm run build:css && concurrently \"npm:dev:*\"", 11 | "start": "remix-serve build", 12 | "generate:sprites": "tsx scripts/sprites.ts" 13 | }, 14 | "dependencies": { 15 | "@prisma/client": "^4.2.1", 16 | "@remix-run/node": "0.0.0-experimental-46aae87fd", 17 | "@remix-run/react": "0.0.0-experimental-46aae87fd", 18 | "@remix-run/serve": "0.0.0-experimental-46aae87fd", 19 | "a11y-dialog": "^7.5.2", 20 | "bcrypt": "^5.0.1", 21 | "clsx": "^1.2.1", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-hook-form": "^7.34.2", 25 | "remix-domains": "^0.3.2", 26 | "remix-forms": "^0.17.4-test.0", 27 | "zod": "^3.18.0", 28 | "zod-form-data": "^1.2.1" 29 | }, 30 | "devDependencies": { 31 | "@remix-run/dev": "0.0.0-experimental-46aae87fd", 32 | "@remix-run/eslint-config": "0.0.0-experimental-46aae87fd", 33 | "@types/bcrypt": "^5.0.0", 34 | "@types/react": "^18.0.15", 35 | "@types/react-dom": "^18.0.6", 36 | "concurrently": "^7.3.0", 37 | "eslint": "^8.20.0", 38 | "prisma": "^4.2.1", 39 | "remix-flat-routes": "^0.4.3", 40 | "svgstore": "^3.0.1", 41 | "tailwindcss": "^3.1.8", 42 | "tsx": "^3.8.2", 43 | "typescript": "^4.7.4" 44 | }, 45 | "engines": { 46 | "node": ">=14" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/sprites.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(cuid()) 15 | username String @unique 16 | hashedPassword String 17 | createdAt DateTime @default(now()) 18 | updatedAt DateTime @updatedAt 19 | projects Project[] 20 | } 21 | 22 | model Project { 23 | id String @id @default(cuid()) 24 | name String 25 | description String? 26 | createdAt DateTime @default(now()) 27 | updatedAt DateTime @updatedAt 28 | models Model[] 29 | users User[] 30 | } 31 | 32 | model Model { 33 | id String @id @default(cuid()) 34 | slug String 35 | name String 36 | description String? 37 | createdAt DateTime @default(now()) 38 | updatedAt DateTime @updatedAt 39 | fields ModelField[] @relation("MODEL_FIELDS_REFERENCE") 40 | typeReferences ModelField[] @relation("MODEL_TYPE_REFERENCE") 41 | project Project? @relation(fields: [projectId], references: [id]) 42 | projectId String? 43 | 44 | @@unique([projectId, slug]) 45 | } 46 | 47 | model ModelField { 48 | id String @id @default(cuid()) 49 | name String 50 | type String 51 | array Boolean 52 | required Boolean 53 | createdAt DateTime @default(now()) 54 | updatedAt DateTime @updatedAt 55 | typeReference Model? @relation("MODEL_TYPE_REFERENCE", fields: [typeReferenceId], references: [id]) 56 | typeReferenceId String? 57 | model Model? @relation("MODEL_FIELDS_REFERENCE", fields: [modelId], references: [id]) 58 | modelId String? 59 | } 60 | -------------------------------------------------------------------------------- /app/routes/dashboard._menu.$projectId.index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useLocation, useOutletContext } from "@remix-run/react"; 3 | import { Link } from "@remix-run/react"; 4 | import type { To } from "react-router-dom"; 5 | 6 | import spritesHref from "~/sprites.svg"; 7 | 8 | export function Layout({ children }: { children: React.ReactNode }) { 9 | const location = useLocation(); 10 | 11 | const openMenuLocation = React.useMemo(() => { 12 | const searchParams = new URLSearchParams(location.search); 13 | searchParams.set("open", "menu"); 14 | return { 15 | pathname: location.pathname, 16 | search: `?${searchParams.toString()}`, 17 | }; 18 | }, [location]); 19 | 20 | return ( 21 |
22 |
23 |
24 |
25 |
26 |
27 | 31 | 38 | 39 |

40 | Dashboard 41 |

42 |
43 |
44 |
45 |
{children}
46 |
47 |
48 |
49 |
50 |
51 | ); 52 | } 53 | 54 | export default function DashboardIndex() { 55 | const menuContents = useOutletContext() as any; 56 | 57 | return {menuContents}; 58 | } 59 | -------------------------------------------------------------------------------- /prisma/migrations/20220824032545_initial/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "username" TEXT NOT NULL, 5 | "hashedPassword" TEXT NOT NULL, 6 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" DATETIME NOT NULL 8 | ); 9 | 10 | -- CreateTable 11 | CREATE TABLE "Project" ( 12 | "id" TEXT NOT NULL PRIMARY KEY, 13 | "name" TEXT NOT NULL, 14 | "description" TEXT, 15 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | "updatedAt" DATETIME NOT NULL 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Model" ( 21 | "id" TEXT NOT NULL PRIMARY KEY, 22 | "slug" TEXT NOT NULL, 23 | "name" TEXT NOT NULL, 24 | "description" TEXT, 25 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | "updatedAt" DATETIME NOT NULL, 27 | "projectId" TEXT, 28 | CONSTRAINT "Model_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE SET NULL ON UPDATE CASCADE 29 | ); 30 | 31 | -- CreateTable 32 | CREATE TABLE "ModelField" ( 33 | "id" TEXT NOT NULL PRIMARY KEY, 34 | "name" TEXT NOT NULL, 35 | "type" TEXT NOT NULL, 36 | "array" BOOLEAN NOT NULL, 37 | "required" BOOLEAN NOT NULL, 38 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 39 | "updatedAt" DATETIME NOT NULL, 40 | "typeReferenceId" TEXT, 41 | "modelId" TEXT, 42 | CONSTRAINT "ModelField_typeReferenceId_fkey" FOREIGN KEY ("typeReferenceId") REFERENCES "Model" ("id") ON DELETE SET NULL ON UPDATE CASCADE, 43 | CONSTRAINT "ModelField_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "Model" ("id") ON DELETE SET NULL ON UPDATE CASCADE 44 | ); 45 | 46 | -- CreateTable 47 | CREATE TABLE "_ProjectToUser" ( 48 | "A" TEXT NOT NULL, 49 | "B" TEXT NOT NULL, 50 | CONSTRAINT "_ProjectToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 51 | CONSTRAINT "_ProjectToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 52 | ); 53 | 54 | -- CreateIndex 55 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 56 | 57 | -- CreateIndex 58 | CREATE UNIQUE INDEX "Model_projectId_slug_key" ON "Model"("projectId", "slug"); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "_ProjectToUser_AB_unique" ON "_ProjectToUser"("A", "B"); 62 | 63 | -- CreateIndex 64 | CREATE INDEX "_ProjectToUser_B_index" ON "_ProjectToUser"("B"); 65 | -------------------------------------------------------------------------------- /app/routes/dashboard_.index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { ShouldReloadFunction } from "@remix-run/react"; 3 | import { useLocation, useOutletContext } from "@remix-run/react"; 4 | import { Link } from "@remix-run/react"; 5 | import type { To } from "react-router-dom"; 6 | 7 | import spritesHref from "~/sprites.svg"; 8 | 9 | export const unstable_shouldReload: ShouldReloadFunction = () => { 10 | return false; 11 | }; 12 | 13 | function Layout({ children }: { children: React.ReactNode }) { 14 | const location = useLocation(); 15 | 16 | const openMenuLocation = React.useMemo(() => { 17 | const searchParams = new URLSearchParams(location.search); 18 | searchParams.set("open", "menu"); 19 | return { 20 | pathname: location.pathname, 21 | search: `?${searchParams.toString()}`, 22 | }; 23 | }, [location]); 24 | 25 | return ( 26 |
27 |
28 |
29 |
30 |
31 |
32 | 36 | 43 | 44 |

45 | Projects 46 |

47 | 52 | 59 | 60 |
61 |
62 |
63 |
{children}
64 |
65 |
66 |
67 |
68 |
69 | ); 70 | } 71 | 72 | export default function DashboardIndex() { 73 | const menuContents = useOutletContext() as any; 74 | 75 | return ( 76 | 77 |
{menuContents}
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { LoaderArgs, MetaFunction } from "@remix-run/node"; 3 | import { json } from "@remix-run/node"; 4 | import type { 5 | ShouldReloadFunction, 6 | UseDataFunctionReturn, 7 | } from "@remix-run/react"; 8 | import { 9 | Links, 10 | LiveReload, 11 | Meta, 12 | Outlet, 13 | Scripts, 14 | ScrollRestoration, 15 | useCatch, 16 | useMatches, 17 | } from "@remix-run/react"; 18 | 19 | import { authenticate } from "~/auth.server"; 20 | import stylesHref from "~/_generated/styles.css"; 21 | 22 | export const links = () => [{ rel: "stylesheet", href: stylesHref }]; 23 | 24 | export const meta: MetaFunction = () => ({ 25 | charset: "utf-8", 26 | title: "New Remix App", 27 | viewport: "width=device-width,initial-scale=1", 28 | }); 29 | 30 | export async function loader({ request }: LoaderArgs) { 31 | const user = await authenticate(request, {}); 32 | 33 | return json({ 34 | authenticated: !!user, 35 | }); 36 | } 37 | 38 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { 39 | if (submission) { 40 | return true; 41 | } 42 | 43 | return false; 44 | }; 45 | 46 | function Document({ children }: { children: React.ReactNode }) { 47 | const matches = useMatches(); 48 | const rootMatch = matches.find((match) => match.id === "root"); 49 | const { authenticated } = (rootMatch?.data || {}) as Partial< 50 | UseDataFunctionReturn 51 | >; 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | {authenticated && ( 61 |
62 | )} 63 | {children} 64 | 65 | 66 | 67 | 68 | 69 | ); 70 | } 71 | 72 | export default function App() { 73 | return ( 74 | 75 | 76 | 77 | ); 78 | } 79 | 80 | export function CatchBoundary() { 81 | const { data, status } = useCatch(); 82 | 83 | const message = 84 | typeof data === "string" && data ? data : messageFromStatus(status); 85 | // const icon = iconFromStatus(status); 86 | 87 | return ( 88 | 89 |
90 |

{status}

91 |
{message}
92 |
93 |
94 | ); 95 | } 96 | 97 | function messageFromStatus(status: number) { 98 | switch (status) { 99 | case 400: 100 | return "Bad Request"; 101 | case 401: 102 | return "Unauthorized"; 103 | case 403: 104 | return "Forbidden"; 105 | case 404: 106 | return "Page not found"; 107 | case 500: 108 | return "Something went wrong"; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /app/routes/dashboard._menu.$projectId.$.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { LoaderArgs } from "@remix-run/node"; 3 | import { json } from "@remix-run/node"; 4 | import type { ShouldReloadFunction } from "@remix-run/react"; 5 | import { Link, useCatch, useLocation } from "@remix-run/react"; 6 | 7 | import { authenticate } from "~/auth.server"; 8 | 9 | import spritesHref from "~/sprites.svg"; 10 | 11 | export async function loader({ request }: LoaderArgs) { 12 | await authenticate(request); 13 | return json(null, 404); 14 | } 15 | 16 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { 17 | if (submission) { 18 | return true; 19 | } 20 | 21 | return false; 22 | }; 23 | 24 | export function CatchBoundary() { 25 | const { status } = useCatch(); 26 | 27 | return ( 28 |
29 |
30 |
31 |

{status}

32 |
33 | Go to the project 34 |
35 |
36 |
37 |
38 | ); 39 | } 40 | 41 | export default function DashboardIndex() { 42 | const location = useLocation(); 43 | 44 | const openMenuLocation = React.useMemo(() => { 45 | const searchParams = new URLSearchParams(location.search); 46 | searchParams.set("open", "menu"); 47 | return { 48 | pathname: location.pathname, 49 | search: `?${searchParams.toString()}`, 50 | }; 51 | }, [location]); 52 | 53 | return ( 54 |
55 |
56 |
57 |
58 |
59 |
60 | 64 | 71 | 72 |

73 | Dashboard 74 |

75 |
76 |
77 |
78 |
79 |
80 |

404

81 |
82 | Go to the project 83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /app/routes/dashboard_.new.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, LoaderArgs } from "@remix-run/node"; 2 | import type { ShouldReloadFunction } from "@remix-run/react"; 3 | import { makeDomainFunction } from "remix-domains"; 4 | import { Form, formAction } from "remix-forms"; 5 | import { z } from "zod"; 6 | 7 | import { authenticate } from "~/auth.server"; 8 | import { DashboardDetailLayout } from "~/components/dashboard"; 9 | import { createProject } from "~/models.server"; 10 | 11 | export async function loader({ request }: LoaderArgs) { 12 | await authenticate(request); 13 | return null; 14 | } 15 | 16 | export const unstable_shouldReload: ShouldReloadFunction = ({ 17 | submission, 18 | prevUrl, 19 | url, 20 | }) => { 21 | if (submission) { 22 | return true; 23 | } 24 | 25 | return prevUrl.pathname !== url.pathname; 26 | }; 27 | 28 | export default function DashboardTest() { 29 | return ( 30 | 31 |
32 | 33 | {({ Errors, Field, Button, register }) => ( 34 | <> 35 | 36 | {({ Label, Errors }) => ( 37 | <> 38 | 49 | 55 | {({ Label, Errors }) => ( 56 | <> 57 | 67 | 68 | 69 | 72 | 73 | )} 74 | 75 |
76 |
77 | ); 78 | } 79 | 80 | const schema = z.object({ 81 | name: z.string().trim().min(1), 82 | description: z.string().trim().optional(), 83 | }); 84 | 85 | export async function action({ request }: ActionArgs) { 86 | const user = await authenticate(request); 87 | 88 | return formAction({ 89 | request, 90 | schema, 91 | successPath: (data: { id: string }) => `/dashboard/${data.id}`, 92 | mutation: makeDomainFunction(schema)(async (input) => { 93 | const project = await createProject({ 94 | userId: user.id, 95 | name: input.name, 96 | description: input.description, 97 | }); 98 | 99 | return { id: project.id }; 100 | }), 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /app/routes/_layout.login.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionArgs, LoaderArgs } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | import { makeDomainFunction } from "remix-domains"; 4 | import { Form, formAction } from "remix-forms"; 5 | import { z } from "zod"; 6 | 7 | import type { AuthenticatedUser } from "~/auth.server"; 8 | import { authenticate, setUser } from "~/auth.server"; 9 | import { sessionStorage } from "~/session.server"; 10 | import { findOrCreateUser } from "~/models.server"; 11 | 12 | export async function loader({ request }: LoaderArgs) { 13 | return authenticate(request, { successRedirect: "/dashboard" }); 14 | } 15 | 16 | export default function Login() { 17 | return ( 18 |
19 |

Login

20 |
21 | {({ Errors, Field, Button, register }) => ( 22 | <> 23 | 29 | {({ Label, Errors }) => ( 30 | <> 31 | 41 | 47 | {({ Label, Errors }) => ( 48 | <> 49 | 59 | 60 | 61 | 62 | )} 63 | 64 |
65 | ); 66 | } 67 | 68 | const schema = z.object({ 69 | username: z.string().trim().min(1), 70 | password: z.string().min(6), 71 | }); 72 | 73 | export async function action({ request }: ActionArgs) { 74 | let user: AuthenticatedUser; 75 | return formAction({ 76 | request, 77 | schema, 78 | mutation: makeDomainFunction(schema)(async ({ password, username }) => { 79 | const foundUser = await findOrCreateUser({ username, password }); 80 | if (!foundUser) throw new Error("Invalid username or password."); 81 | user = foundUser; 82 | return foundUser; 83 | }), 84 | // This is to get around the lack of ability to set a cookie from a domain func. 85 | beforeSuccess: async (request) => { 86 | const session = await sessionStorage.getSession( 87 | request.headers.get("Cookie") 88 | ); 89 | setUser(session, user); 90 | 91 | return redirect("/dashboard", { 92 | headers: { 93 | "Set-Cookie": await sessionStorage.commitSession(session), 94 | }, 95 | }); 96 | }, 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /app/models/models.server.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "~/prisma.server"; 2 | 3 | import type { DataTypes } from "~/app.config"; 4 | 5 | export async function createModelForProject({ 6 | userId, 7 | projectId, 8 | slug, 9 | name, 10 | description, 11 | fields, 12 | }: { 13 | userId: string; 14 | projectId: string; 15 | slug: string; 16 | name: string; 17 | description?: string; 18 | fields?: { 19 | name: string; 20 | type: DataTypes; 21 | required: boolean; 22 | array: boolean; 23 | }[]; 24 | }) { 25 | const project = await prisma.project.findFirst({ 26 | where: { 27 | id: projectId, 28 | users: { 29 | some: { 30 | id: userId, 31 | }, 32 | }, 33 | }, 34 | select: { 35 | id: true, 36 | }, 37 | }); 38 | 39 | if (!project) return undefined; 40 | 41 | const model = await prisma.model.create({ 42 | data: { 43 | slug, 44 | name, 45 | description, 46 | projectId, 47 | fields: fields?.length 48 | ? { 49 | create: fields, 50 | } 51 | : undefined, 52 | }, 53 | }); 54 | 55 | return { id: model.id }; 56 | } 57 | 58 | export async function getFieldsForModel({ 59 | modelId, 60 | userId, 61 | }: { 62 | modelId: string; 63 | userId: string; 64 | }) { 65 | const model = await prisma.model.findFirst({ 66 | where: { 67 | id: modelId, 68 | project: { 69 | users: { 70 | some: { 71 | id: userId, 72 | }, 73 | }, 74 | }, 75 | }, 76 | select: { 77 | fields: { 78 | select: { 79 | id: true, 80 | name: true, 81 | type: true, 82 | required: true, 83 | array: true, 84 | typeReference: { 85 | select: { 86 | id: true, 87 | name: true, 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | }); 94 | 95 | if (!model) return []; 96 | 97 | return model.fields.map((field) => ({ 98 | id: field.id, 99 | name: field.name, 100 | type: field.type, 101 | required: field.required, 102 | array: field.array, 103 | typeReferenceId: field.typeReference?.id, 104 | typeReferenceName: field.typeReference?.name, 105 | })); 106 | } 107 | 108 | export async function getModelsForProject({ 109 | projectId, 110 | userId, 111 | }: { 112 | projectId: string; 113 | userId: string; 114 | }) { 115 | const models = await prisma.model.findMany({ 116 | where: { 117 | project: { 118 | id: projectId, 119 | users: { 120 | some: { 121 | id: userId, 122 | }, 123 | }, 124 | }, 125 | }, 126 | select: { 127 | id: true, 128 | slug: true, 129 | name: true, 130 | description: true, 131 | }, 132 | }); 133 | 134 | return models.map(({ id, slug, name, description }) => ({ 135 | id, 136 | slug, 137 | name, 138 | description: description || undefined, 139 | })); 140 | } 141 | 142 | export async function getModelOverview({ 143 | modelId, 144 | userId, 145 | }: { 146 | modelId: string; 147 | userId: string; 148 | }) { 149 | const model = await prisma.model.findFirst({ 150 | where: { 151 | id: modelId, 152 | project: { 153 | users: { 154 | some: { 155 | id: userId, 156 | }, 157 | }, 158 | }, 159 | }, 160 | select: { 161 | id: true, 162 | slug: true, 163 | name: true, 164 | description: true, 165 | }, 166 | }); 167 | 168 | if (!model) return undefined; 169 | 170 | return { 171 | id: model.id, 172 | slug: model.slug, 173 | name: model.name, 174 | description: model.description, 175 | }; 176 | } 177 | 178 | export async function countModels({ 179 | userId, 180 | projectId, 181 | }: { 182 | userId: string; 183 | projectId: string; 184 | }): Promise { 185 | const project = await prisma.project.findFirst({ 186 | where: { 187 | id: projectId, 188 | users: { 189 | some: { 190 | id: userId, 191 | }, 192 | }, 193 | }, 194 | select: { 195 | _count: { 196 | select: { 197 | models: true, 198 | }, 199 | }, 200 | }, 201 | }); 202 | 203 | return project?._count.models || 0; 204 | } 205 | -------------------------------------------------------------------------------- /app/routes/dashboard._menu.$projectId.models.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { LoaderArgs } from "@remix-run/node"; 3 | import { defer } from "@remix-run/node"; 4 | import type { ShouldReloadFunction } from "@remix-run/react"; 5 | import { 6 | Await, 7 | Link, 8 | useCatch, 9 | useLoaderData, 10 | useParams, 11 | } from "@remix-run/react"; 12 | 13 | import { authenticate } from "~/auth.server"; 14 | import { DashboardListLayout, DashboardListItem } from "~/components/dashboard"; 15 | import { getModelsForProject } from "~/models.server"; 16 | 17 | import spritesHref from "~/sprites.svg"; 18 | 19 | import { Layout } from "./dashboard._menu.$projectId.index"; 20 | 21 | export async function loader({ request, params }: LoaderArgs) { 22 | const user = await authenticate(request); 23 | 24 | const modelsPromise = getModelsForProject({ 25 | projectId: params.projectId!, 26 | userId: user.id, 27 | }); 28 | 29 | return defer({ 30 | models: modelsPromise, 31 | }); 32 | } 33 | 34 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { 35 | if (submission) { 36 | return true; 37 | } 38 | 39 | return false; 40 | }; 41 | 42 | export function CatchBoundary() { 43 | const { status } = useCatch(); 44 | return ( 45 | 46 |
47 |

{status}

48 |
49 | Go to the project models 50 |
51 |
52 |
53 | ); 54 | } 55 | 56 | export default function ModelsLayout() { 57 | const { models } = useLoaderData(); 58 | const { modelId } = useParams(); 59 | 60 | return ( 61 | 67 | 72 | 79 | 80 | 81 | } 82 | > 83 | 89 | 105 | Loading... 106 | 107 | } 108 | > 109 | 110 | {(models) => 111 | models.map(({ id, slug, name, description }) => ( 112 | 120 | )) 121 | } 122 | 123 | 124 | 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /app/routes/dashboard._menu.$projectId.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { LoaderArgs } from "@remix-run/node"; 3 | import { defer, json } from "@remix-run/node"; 4 | import type { ShouldReloadFunction } from "@remix-run/react"; 5 | import { Await, Link, Outlet, useCatch, useLoaderData } from "@remix-run/react"; 6 | 7 | import * as appConfig from "~/app.config"; 8 | import { authenticate } from "~/auth.server"; 9 | import { DashboardMenu } from "~/components/dashboard"; 10 | import { countContent, countModels, projectExists } from "~/models.server"; 11 | 12 | import spritesHref from "~/sprites.svg"; 13 | 14 | export async function loader({ request, params }: LoaderArgs) { 15 | const user = await authenticate(request); 16 | 17 | if ( 18 | !(await projectExists({ projectId: params.projectId!, userId: user.id })) 19 | ) { 20 | throw json(null, 404); 21 | } 22 | 23 | const contentCountPromise = countContent({ 24 | projectId: params.projectId!, 25 | userId: user.id, 26 | }); 27 | const modelsCountPromise = countModels({ 28 | projectId: params.projectId!, 29 | userId: user.id, 30 | }); 31 | 32 | return defer({ 33 | contentCount: contentCountPromise, 34 | modelsCount: modelsCountPromise, 35 | }); 36 | } 37 | 38 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { 39 | if (submission) { 40 | return true; 41 | } 42 | 43 | return false; 44 | }; 45 | 46 | export function CatchBoundary() { 47 | const { status } = useCatch(); 48 | 49 | return ( 50 |
51 |
52 |
53 |

{status}

54 |
55 | Go to the dashboard 56 |
57 |
58 |
59 |
60 | ); 61 | } 62 | 63 | export default function DashboardMenuLayout() { 64 | const { contentCount, modelsCount } = useLoaderData(); 65 | 66 | const menuContents = ( 67 |
    68 |
  • 69 | 73 | {" "} 76 |
    Models
    77 | 78 | 79 | {(count) => {count}} 80 | 81 | 82 | 83 |
  • 84 |
  • 85 | 89 | {" "} 92 |
    Content
    93 | 94 | 95 | {(count) => {count}} 96 | 97 | 98 | 99 |
  • 100 |
101 | ); 102 | 103 | return ( 104 | <> 105 | 106 |
107 |
108 |
109 |
110 |
111 |
112 |
Project
113 |
114 |
115 |
{menuContents}
116 |
117 |
118 |
119 |
120 |
121 |
    122 |
  • 123 | 127 | {" "} 130 | Docs 131 | 132 |
  • 133 |
  • 134 | 144 |
  • 145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 | 153 | 154 | ); 155 | } 156 | -------------------------------------------------------------------------------- /app/routes/dashboard._menu.$projectId.models.$modelId.$.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { LoaderArgs } from "@remix-run/node"; 3 | import { defer, json } from "@remix-run/node"; 4 | import { 5 | Await, 6 | Link, 7 | useLoaderData, 8 | useLocation, 9 | useNavigate, 10 | useParams, 11 | } from "@remix-run/react"; 12 | 13 | import { authenticate } from "~/auth.server"; 14 | import { getField } from "~/models.server"; 15 | 16 | import spritesHref from "~/sprites.svg"; 17 | 18 | export async function loader({ request, params }: LoaderArgs) { 19 | const user = await authenticate(request); 20 | const { "*": slug } = params; 21 | 22 | const { sections } = getSections(slug); 23 | 24 | if (!sections.length) { 25 | throw json({}, 404); 26 | } 27 | 28 | const trimmedSections: { id: string; type: "field"; to: string }[] = []; 29 | type SectionWithData = typeof sections[number] & { 30 | data: Promise>; 31 | }; 32 | const sectionPromises = sections 33 | .map(({ type, id }, index) => { 34 | const to = 35 | `/dashboard/${params.projectId}/models/${params.modelId}/` + 36 | sections 37 | .slice(0, index + 1) 38 | .map((section) => `${section.type}/${section.id}`) 39 | .join("/"); 40 | if (type === "field") { 41 | trimmedSections.push({ id, type, to }); 42 | return { 43 | type: type, 44 | id, 45 | data: getField({ fieldId: id, userId: user.id }), 46 | }; 47 | } 48 | return false as any; 49 | }) 50 | .filter(Boolean) as SectionWithData[]; 51 | 52 | return defer({ 53 | sections: trimmedSections.reverse(), 54 | ...Object.fromEntries( 55 | sectionPromises.map(({ id, data }) => [`section_${id}` as const, data]) 56 | ), 57 | } as Record>> & { 58 | sections: typeof trimmedSections; 59 | }); 60 | } 61 | 62 | function getSections(splat?: string) { 63 | const splitSplat = (splat || "").split("/"); 64 | let type = ""; 65 | let returnToFieldId = undefined; 66 | const sections = []; 67 | for (const section of splitSplat) { 68 | if (section === "field") { 69 | type = section; 70 | continue; 71 | } 72 | 73 | if (!returnToFieldId && type === "field") { 74 | returnToFieldId = section; 75 | } 76 | 77 | if (type) { 78 | sections.push({ type, id: section }); 79 | type = ""; 80 | } 81 | } 82 | return { returnToFieldId, sections }; 83 | } 84 | 85 | export default function FieldDashboard() { 86 | const loaderData = useLoaderData(); 87 | const location = useLocation(); 88 | const navigate = useNavigate(); 89 | const { "*": splat, projectId, modelId } = useParams(); 90 | 91 | const sectionData = React.useMemo( 92 | () => 93 | loaderData.sections.reduce((acc, section) => { 94 | acc[section.id] = loaderData[`section_${section.id}`]; 95 | return acc; 96 | }, {} as Record), 97 | [loaderData] 98 | ); 99 | 100 | const { returnToFieldId } = React.useMemo(() => getSections(splat), [splat]); 101 | 102 | // This is here to trap focus in the dialog. The "open" 103 | // attribute doesn't do this for some stupid reason. 104 | const dialogRef = React.useRef(null); 105 | React.useEffect(() => { 106 | if (dialogRef.current) { 107 | dialogRef.current.close("focus"); 108 | dialogRef.current.showModal(); 109 | } 110 | }, [dialogRef, location.key]); 111 | 112 | const focusedSection = sectionData[loaderData.sections[0].id]; 113 | 114 | return ( 115 | <> 116 | { 123 | e.stopPropagation(); 124 | }} 125 | onClose={(e) => { 126 | if (e.currentTarget.returnValue !== "focus") { 127 | navigate("..", { state: { fieldId: returnToFieldId } }); 128 | } else { 129 | e.currentTarget.returnValue = ""; 130 | } 131 | }} 132 | > 133 |
134 |
135 |
136 |
137 | 142 | 145 | 146 |
147 |
148 | 149 | 150 | {(section) => ( 151 | <> 152 |

{section.name}

153 | 154 | 155 | )} 156 |
157 |
158 |
159 |
160 | {loaderData.sections.length > 1 && ( 161 | 200 | )} 201 |
202 |
203 |
204 | 205 | ); 206 | } 207 | -------------------------------------------------------------------------------- /app/components/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { To } from "react-router-dom"; 3 | import { 4 | Link, 5 | Outlet, 6 | useLocation, 7 | useMatches, 8 | useSearchParams, 9 | } from "@remix-run/react"; 10 | import clsx from "clsx"; 11 | 12 | import spritesHref from "~/sprites.svg"; 13 | 14 | interface DashboardMenuProps { 15 | children: React.ReactNode; 16 | title: React.ReactNode; 17 | openIndicator: string; 18 | } 19 | 20 | export function DashboardMenu({ 21 | children, 22 | title, 23 | openIndicator, 24 | }: DashboardMenuProps) { 25 | const location = useLocation(); 26 | const [searchParams] = useSearchParams(); 27 | 28 | const menuOpen = searchParams.get("open") === openIndicator; 29 | 30 | const closeMenuLocation = React.useMemo(() => { 31 | const searchParams = new URLSearchParams(location.search); 32 | searchParams.delete("open"); 33 | return { 34 | pathname: location.pathname, 35 | search: `?${searchParams.toString()}`, 36 | }; 37 | }, [location]); 38 | 39 | return ( 40 | 74 | ); 75 | } 76 | 77 | interface DashboardListLayoutProps { 78 | children: React.ReactNode; 79 | title: React.ReactNode; 80 | openIndicator: string; 81 | routeId: string; 82 | header?: React.ReactNode; 83 | } 84 | 85 | export function DashboardListLayout({ 86 | children, 87 | title, 88 | openIndicator, 89 | routeId, 90 | header, 91 | }: DashboardListLayoutProps) { 92 | const location = useLocation(); 93 | const matches = useMatches(); 94 | 95 | const openMenuLocation = React.useMemo(() => { 96 | const searchParams = new URLSearchParams(location.search); 97 | searchParams.set("open", openIndicator); 98 | return { 99 | pathname: location.pathname, 100 | search: `?${searchParams.toString()}`, 101 | }; 102 | }, [location, openIndicator]); 103 | 104 | const showRouteAsCritical = React.useMemo(() => { 105 | const selfIndex = matches.findIndex(({ id }) => id === routeId); 106 | const nextMatch = matches[selfIndex + 1]; 107 | return ( 108 | nextMatch?.id.endsWith("index") || nextMatch?.id.endsWith("$") || false 109 | ); 110 | }, [matches, routeId]); 111 | 112 | return ( 113 |
114 |
115 |
121 |
122 |
123 |
124 | 128 | 135 | 136 |
137 | {title} 138 |
139 | {header} 140 |
141 |
142 |
143 |
{children}
144 |
145 |
146 |
147 | 148 |
149 |
150 | ); 151 | } 152 | 153 | export interface DashboardListItemProps { 154 | to: To; 155 | title: string; 156 | description?: string; 157 | header?: React.ReactNode; 158 | selected?: boolean; 159 | } 160 | 161 | export function DashboardListItem({ 162 | to, 163 | title, 164 | description, 165 | header, 166 | selected, 167 | }: DashboardListItemProps) { 168 | return ( 169 |
170 | 178 |
179 |
{header}
180 | 181 |

{title}

182 | {description && ( 183 |

{description}

184 | )} 185 |
186 | 187 |
188 | ); 189 | } 190 | 191 | interface DashboardDetailLayoutProps { 192 | children: React.ReactNode; 193 | title?: React.ReactNode; 194 | } 195 | 196 | export function DashboardDetailLayout({ 197 | children, 198 | title, 199 | }: DashboardDetailLayoutProps) { 200 | return ( 201 |
202 |
203 |
204 |
205 |
206 |
207 | 212 | 219 | 220 | {title && ( 221 |

222 | {title} 223 |

224 | )} 225 |
226 |
227 | {children} 228 |
229 |
230 |
231 |
232 | ); 233 | } 234 | -------------------------------------------------------------------------------- /app/routes/dashboard._menu.$projectId.models.$modelId.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { LoaderArgs } from "@remix-run/node"; 3 | import { defer, json } from "@remix-run/node"; 4 | import type { ShouldReloadFunction } from "@remix-run/react"; 5 | import { 6 | Await, 7 | Link, 8 | Outlet, 9 | useLoaderData, 10 | useLocation, 11 | } from "@remix-run/react"; 12 | import { Form } from "remix-forms"; 13 | import { z } from "zod"; 14 | import clsx from "clsx"; 15 | 16 | import { authenticate } from "~/auth.server"; 17 | import { DashboardDetailLayout } from "~/components/dashboard"; 18 | import { getModelOverview, getFieldsForModel } from "~/models.server"; 19 | 20 | import spritesHref from "~/sprites.svg"; 21 | 22 | export async function loader({ request, params }: LoaderArgs) { 23 | const user = await authenticate(request); 24 | 25 | const model = await getModelOverview({ 26 | modelId: params.modelId!, 27 | userId: user.id, 28 | }); 29 | 30 | const fieldsPromise = getFieldsForModel({ 31 | modelId: params.modelId!, 32 | userId: user.id, 33 | }); 34 | 35 | if (!model) { 36 | throw json(null, 404); 37 | } 38 | 39 | return defer({ model, fields: fieldsPromise }); 40 | } 41 | 42 | export const unstable_shouldReload: ShouldReloadFunction = ({ 43 | submission, 44 | prevUrl, 45 | url, 46 | }) => { 47 | if (submission) { 48 | return true; 49 | } 50 | 51 | return prevUrl.pathname !== url.pathname; 52 | }; 53 | 54 | export default function DashboardTest() { 55 | const { model, fields } = useLoaderData(); 56 | const location = useLocation(); 57 | 58 | const backToFieldId = React.useMemo(() => { 59 | const { fieldId } = ((typeof location.state === "object" 60 | ? location.state 61 | : {}) || {}) as { fieldId?: string }; 62 | return fieldId; 63 | }, [location]); 64 | const backToFieldLinkRef = React.useRef(null); 65 | React.useEffect(() => { 66 | if (backToFieldLinkRef.current) { 67 | backToFieldLinkRef.current.focus(); 68 | } 69 | }, [backToFieldId, backToFieldLinkRef]); 70 | 71 | return ( 72 | 73 |
74 |
75 | {({ control, Errors, Field, Button, register, formState }) => ( 76 | <> 77 | 78 | {({ Label, Errors }) => ( 79 | <> 80 | 92 | 93 | {({ Label, Errors }) => ( 94 | <> 95 | 108 | 109 | 112 | 113 | )} 114 | 115 | 116 |

117 | Fields 118 | 131 |

132 | 138 | 154 | Loading... 155 |
156 | } 157 | > 158 | 159 | {(fields) => ( 160 |
    161 | {fields.map( 162 | ({ id, name, array, required, type, typeReferenceName }) => ( 163 |
  • 164 | 175 |
    176 |
    177 | {required ? "required" : "optional"} 178 |
    179 | 180 |

    {name}

    181 | 182 |

    183 | {array && "Array<"} 184 | {typeReferenceName || type} 185 | {array && ">"} 186 |

    187 |
    188 | 189 |
  • 190 | ) 191 | )} 192 |
193 | )} 194 |
195 | 196 | 197 | 198 |
199 | ); 200 | } 201 | 202 | const schema = z.object({ 203 | name: z.string().trim().min(1), 204 | slug: z.string().trim().min(1), 205 | description: z.string().trim().optional(), 206 | }); 207 | -------------------------------------------------------------------------------- /app/routes/dashboard_.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { LoaderArgs } from "@remix-run/node"; 3 | import { defer } from "@remix-run/node"; 4 | import type { ShouldReloadFunction } from "@remix-run/react"; 5 | import { Await, Link, Outlet, useLoaderData } from "@remix-run/react"; 6 | 7 | import * as appConfig from "~/app.config"; 8 | import { authenticate } from "~/auth.server"; 9 | import { DashboardMenu } from "~/components/dashboard"; 10 | import { getProjects } from "~/models.server"; 11 | 12 | import spritesHref from "~/sprites.svg"; 13 | 14 | export async function loader({ request, params }: LoaderArgs) { 15 | const user = await authenticate(request); 16 | 17 | const projectsPromise = getProjects({ userId: user.id }); 18 | 19 | return defer({ 20 | projects: projectsPromise, 21 | }); 22 | } 23 | 24 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { 25 | if (submission) { 26 | return true; 27 | } 28 | 29 | return false; 30 | }; 31 | 32 | export default function DashboardMenuLayout() { 33 | const { projects } = useLoaderData(); 34 | 35 | const menuContents = ( 36 | 39 | 55 | Loading... 56 | 57 | } 58 | > 59 | 60 | {(projects) => ( 61 |
    62 | {projects.map(({ id, name, description }) => ( 63 |
  • 64 | 68 | 75 |
    76 | {name} 77 | {description && ( 78 | 79 | {description} 80 | 81 | )} 82 |
    83 | 84 |
  • 85 | ))} 86 |
87 | )} 88 |
89 |
90 | ); 91 | 92 | return ( 93 | <> 94 | 95 |
96 |
97 |
98 | 101 |
102 |
103 |
104 |
Projects
105 |
109 | 125 | Loading... 126 |
127 |
128 |
129 |
130 |
131 |
132 | } 133 | > 134 | 135 |
136 |
137 |
138 |
Projects
139 |
140 |
141 |
{menuContents}
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
    150 |
  • 151 | 155 | {" "} 158 | Docs 159 | 160 |
  • 161 |
  • 162 | 172 |
  • 173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 | 181 | 182 | ); 183 | } 184 | -------------------------------------------------------------------------------- /app/routes/dashboard._menu.$projectId.models.new.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { ActionArgs, LoaderArgs } from "@remix-run/node"; 3 | import type { ShouldReloadFunction } from "@remix-run/react"; 4 | import { useActionData } from "@remix-run/react"; 5 | import { InputError, InputErrors, makeDomainFunction } from "remix-domains"; 6 | import { Form, formAction } from "remix-forms"; 7 | import { useFieldArray } from "react-hook-form"; 8 | import { z } from "zod"; 9 | import { zfd } from "zod-form-data"; 10 | import clsx from "clsx"; 11 | 12 | import * as appConfig from "~/app.config"; 13 | import { authenticate } from "~/auth.server"; 14 | import { DashboardDetailLayout } from "~/components/dashboard"; 15 | import { createModelForProject } from "~/models.server"; 16 | 17 | import spritesHref from "~/sprites.svg"; 18 | 19 | export async function loader({ request }: LoaderArgs) { 20 | await authenticate(request); 21 | return null; 22 | } 23 | 24 | export const unstable_shouldReload: ShouldReloadFunction = ({ 25 | submission, 26 | prevUrl, 27 | url, 28 | }) => { 29 | if (submission) { 30 | return true; 31 | } 32 | 33 | return prevUrl.pathname !== url.pathname; 34 | }; 35 | 36 | function TmpErrors({ 37 | formState, 38 | id, 39 | field, 40 | }: { 41 | formState: any; 42 | id: string; 43 | field: (string | number)[]; 44 | }) { 45 | const errors: string[] | undefined = React.useMemo( 46 | () => getError(formState, field), 47 | [formState, field] 48 | ); 49 | return errors?.length ? ( 50 | 55 | ) : null; 56 | } 57 | 58 | function getError(formState: any, field: (string | number)[]) { 59 | let value = field.length > 0 ? formState?.errors : null; 60 | for (let i = 0; i < field.length && value; i++) { 61 | value = value[field[i]] || value[String(field[i])]; 62 | } 63 | return value; 64 | } 65 | 66 | function FieldsForm({ 67 | Field, 68 | control, 69 | register, 70 | }: { 71 | Field: any; 72 | control: any; 73 | register: any; 74 | }) { 75 | const actionData = useActionData(); 76 | const { fields, append, remove } = useFieldArray({ control, name: "fields" }); 77 | 78 | return ( 79 | <> 80 |

81 | Fields 82 | 92 |

93 | {fields.map((field, index) => { 94 | return ( 95 |
0 && "mt-4")} 97 | key={field.id} 98 | > 99 | 104 | {({ Label }: any) => ( 105 | <> 106 | 126 | 127 | 130 | 141 | 142 | 143 | {({ Label }: any) => ( 144 | <> 145 | 159 | 164 | 165 | )} 166 | 167 | 168 | 173 | {({ Label }: any) => ( 174 | <> 175 | 189 | 194 | 195 | )} 196 | 197 | 198 | 205 |
206 | ); 207 | })} 208 | 209 | ); 210 | } 211 | 212 | export default function DashboardTest() { 213 | return ( 214 | 215 |
216 |
217 | {({ control, Errors, Field, Button, register, formState }) => ( 218 | <> 219 | 220 | {({ Label, Errors }) => ( 221 | <> 222 | 232 | 233 | {({ Label, Errors }) => ( 234 | <> 235 | 246 | 247 | 248 | 249 | 250 | 253 | 254 | )} 255 | 256 |
257 |
258 | ); 259 | } 260 | 261 | const fieldsSchema = z 262 | .array( 263 | z.object({ 264 | name: z.string().trim().min(1), 265 | type: z.enum( 266 | appConfig.dataTypes 267 | ), 268 | array: zfd.checkbox({ trueValue: "" }), 269 | required: zfd.checkbox({ trueValue: "" }), 270 | }) 271 | ) 272 | .optional(); 273 | 274 | const schema = z.object({ 275 | name: z.string().trim().min(1), 276 | slug: z.string().trim().min(1), 277 | description: z.string().trim().optional(), 278 | fields: z.any().optional(), 279 | }); 280 | 281 | export async function action({ request, params }: ActionArgs) { 282 | const user = await authenticate(request); 283 | 284 | return formAction({ 285 | request, 286 | schema, 287 | successPath: (data: { id: string }) => 288 | `/dashboard/${params.projectId}/models/${data.id}`, 289 | mutation: makeDomainFunction(schema)(async (input) => { 290 | const fields = await fieldsSchema.safeParseAsync(input.fields); 291 | if (!fields.success) { 292 | throw new InputErrors( 293 | fields.error.issues.map((issue) => ({ 294 | path: issue.path.reduce((acc, key, index) => { 295 | if (index === 0) { 296 | return `fields.${key}`; 297 | } 298 | if (typeof key === "number") { 299 | return `${acc}[${key}]`; 300 | } 301 | return `${acc}.${key}`; 302 | }, ""), 303 | message: issue.message, 304 | })) 305 | ); 306 | } 307 | 308 | const model = await createModelForProject({ 309 | userId: user.id, 310 | projectId: params.projectId!, 311 | slug: input.slug, 312 | name: input.name, 313 | description: input.description, 314 | fields: fields.data, 315 | }); 316 | 317 | if (!model) throw new InputError("Failed to create a model", "name"); 318 | 319 | return { id: model.id }; 320 | }), 321 | }); 322 | } 323 | --------------------------------------------------------------------------------