Showing preview only (250K chars total). Download the full file or copy to clipboard to get everything.
Repository: apiannie/apiannie
Branch: main
Commit: f4e360467d29
Files: 62
Total size: 232.9 KB
Directory structure:
gitextract_0qb6gbce/
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── app/
│ ├── context.tsx
│ ├── createEmotionCache.ts
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── models/
│ │ ├── api.server.ts
│ │ ├── prisma.server.ts
│ │ ├── project.server.ts
│ │ ├── type.ts
│ │ └── user.server.ts
│ ├── root.tsx
│ ├── routes/
│ │ ├── home/
│ │ │ ├── ..lib/
│ │ │ │ ├── ColorModeButton.tsx
│ │ │ │ ├── Layout.tsx
│ │ │ │ ├── NotificationButton.tsx
│ │ │ │ └── UserMenuButton.tsx
│ │ │ ├── logout.tsx
│ │ │ ├── settings.tsx
│ │ │ ├── signin.tsx
│ │ │ └── signup.tsx
│ │ ├── index.tsx
│ │ ├── mock/
│ │ │ └── $projectId.$.tsx
│ │ └── projects/
│ │ ├── $projectId/
│ │ │ ├── activities.tsx
│ │ │ ├── apis/
│ │ │ │ ├── ..api.tsx
│ │ │ │ ├── ..editor.tsx
│ │ │ │ ├── ..postman.tsx
│ │ │ │ ├── details.$apiId.tsx
│ │ │ │ ├── groups.$groupId.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── apis.tsx
│ │ │ ├── settings/
│ │ │ │ ├── index.tsx
│ │ │ │ └── members.tsx
│ │ │ └── settings.tsx
│ │ ├── $projectId.tsx
│ │ └── index.tsx
│ ├── session.server.ts
│ ├── theme.ts
│ ├── ui/
│ │ ├── AceEditor.tsx
│ │ ├── Form/
│ │ │ ├── FormCancelButton.tsx
│ │ │ ├── FormHInput.tsx
│ │ │ ├── FormInput.tsx
│ │ │ ├── FormModal.tsx
│ │ │ ├── FormSubmitButton.tsx
│ │ │ ├── ModalInput.tsx
│ │ │ ├── PathInput.tsx
│ │ │ └── type.ts
│ │ ├── Header.tsx
│ │ ├── _AceEditor.tsx
│ │ ├── dashboard.css
│ │ └── index.ts
│ └── utils/
│ ├── hooks.ts
│ ├── index.ts
│ ├── mock.ts
│ └── treeBuilder.ts
├── package.json
├── prisma/
│ ├── schema.prisma
│ └── seed.ts
├── remix.config.js
├── remix.env.d.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# Build
build/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
.idea
yarn.lock
package-lock.json
================================================
FILE: .npmrc
================================================
legacy-peer-deps=true
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: README.md
================================================
<h1 align="center">
<a href="https://apiannie.com">
<img alt="ApiAnnie" src="https://user-images.githubusercontent.com/4088232/199437591-23d65512-2d66-4ba6-ae77-5f1748e5bdca.png" width="500">
</a>
<br><br>
<small>A lightweight web tool for API documentation and development</small>
</h1>
> ℹ️ Api Annie is currently still in pre-release mode.ℹ️
Api Annie helps your team to co-ordinate easier when developing a product which needs API comunication, by going through the following steps:
1. **[Define]** Define your API structure.
2. **[Mock]** Frontend developer makes their prototype by using the mock server automatically generated by Api Annie.
3. **[Execute]** Backend developer builds the backend services and test it by sending requests through Api Annie
4. **[Integrate]** When both frontend and backend development are finished, let frontend switch communication from Api Annie to your backend server URL.
## Installation
### Cloud service
We recommend using our [cloud service](https://apiannie.com), where you can always get access to the latest features.
### Manual installation
If you prefer deploying Api Annie in your own machine, run:
```
git clone git@github.com:apiannie/apiannie.git
cd apiannie
```
Then create a file named `.env`, which should contain the following environment variables:
```
DATABASE_URL="mongodb+srv://USERNAME:PASSWORD@HOST/DATABASE"
SESSION_SECRET="replace-it-by-random-string"
```
For more info about connection to MongoDB, please refer to [this doc from Prisma](https://www.prisma.io/docs/concepts/database-connectors/mongodb)
Then install dependency packages, and initalize database:
```
npm install
npx prisma db push
```
or if you are using yarn
```
yarn install
yarn prisma db push
```
Up to now, you have your environment successfully setup, to run Api Annie in dev mode:
`npm run dev` or `yarn dev`
To run in release mode:
```
npm run build
npm start
```
or if you are using yarn
```
yarn build
yarn start
```
## Tech stack
- [Remix](https://github.com/remix-run/remix) as the primary full stack web framework
- Database ORM with [Prisma](https://github.com/prisma/prisma)
- Static Types with [TypeScript](https://www.typescriptlang.org/)
- UI components built with [Chakra-UI](https://github.com/chakra-ui/chakra-ui)
- Form validation with [remix-validated-form](https://github.com/airjp73/remix-validated-form) and [Zod](https://github.com/colinhacks/zod)
- [Chance.js](https://github.com/chancejs/chancejs) for random data generate in mock server
## License
This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/apiannie/apiannie/blob/readme-update/LICENSE) file for details.
================================================
FILE: app/context.tsx
================================================
// context.tsx
import React, { createContext } from "react";
export interface ServerStyleContextData {
key: string;
ids: Array<string>;
css: string;
}
export const ServerStyleContext = createContext<
ServerStyleContextData[] | null
>(null);
export interface ClientStyleContextData {
reset: () => void;
}
export const ClientStyleContext = createContext<ClientStyleContextData | null>(
null
);
================================================
FILE: app/createEmotionCache.ts
================================================
// createEmotionCache.ts
import createCache from "@emotion/cache";
export default function createEmotionCache() {
return createCache({ key: "css" });
}
================================================
FILE: app/entry.client.tsx
================================================
// entry.client.tsx
import React, { useState } from "react";
import { hydrateRoot } from "react-dom/client";
import { CacheProvider } from "@emotion/react";
import { RemixBrowser } from "@remix-run/react";
import { ClientStyleContext } from "./context";
import createEmotionCache from "./createEmotionCache";
interface ClientCacheProviderProps {
children: React.ReactNode;
}
function ClientCacheProvider({ children }: ClientCacheProviderProps) {
const [cache, setCache] = useState(createEmotionCache());
function reset() {
setCache(createEmotionCache());
}
return (
<ClientStyleContext.Provider value={{ reset }}>
<CacheProvider value={cache}>{children}</CacheProvider>
</ClientStyleContext.Provider>
);
}
hydrateRoot(
document,
<ClientCacheProvider>
<RemixBrowser />
</ClientCacheProvider>
);
================================================
FILE: app/entry.server.tsx
================================================
// entry.server.tsx
import { renderToString } from "react-dom/server";
import { CacheProvider } from "@emotion/react";
import createEmotionServer from "@emotion/server/create-instance";
import { RemixServer } from "@remix-run/react";
import type { EntryContext } from "@remix-run/node"; // Depends on the runtime you choose
import { ServerStyleContext } from "./context";
import createEmotionCache from "./createEmotionCache";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
const html = renderToString(
<ServerStyleContext.Provider value={null}>
<CacheProvider value={cache}>
<RemixServer context={remixContext} url={request.url} />
</CacheProvider>
</ServerStyleContext.Provider>
);
const chunks = extractCriticalToChunks(html);
const markup = renderToString(
<ServerStyleContext.Provider value={chunks.styles}>
<CacheProvider value={cache}>
<RemixServer context={remixContext} url={request.url} />
</CacheProvider>
</ServerStyleContext.Provider>
);
responseHeaders.set("Content-Type", "text/html");
return new Response(`<!DOCTYPE html>${markup}`, {
status: responseStatusCode,
headers: responseHeaders,
});
}
================================================
FILE: app/models/api.server.ts
================================================
import { RequestParam } from ".prisma/client";
import { ApiData, RequestMethod } from "@prisma/client";
import { prisma } from "./prisma.server";
import { getProjectById, Group } from "./project.server";
export const createGroup = async ({
parentId,
projectId,
name,
}: {
parentId?: string;
projectId: string;
name: string;
}) => {
if (parentId === "") {
parentId = undefined;
}
return prisma.group.create({
data: {
projectId: projectId,
parentId: parentId,
name: name,
},
});
};
export const updateGroup = async ({
id,
parentId,
name,
description,
}: {
id: string;
name?: string;
parentId?: string | undefined;
description?: string;
}) => {
return prisma.group.update({
where: {
id: id,
},
data: {
name,
description,
parentId,
},
});
};
export const getGroupById = async (id: string) => {
let group = await prisma.group.findFirst({
where: {
id: id,
},
});
if (!group) {
return null;
}
group.description ||= "";
return group;
};
export const createApi = async (
projectId: string,
groupId: string | undefined,
data: {
name: string;
path: string;
method: RequestMethod;
pathParams: RequestParam[];
}
) => {
if (groupId === "") {
groupId = undefined;
}
let api = await prisma.api.create({
data: {
projectId: projectId,
groupId: groupId,
data: data,
},
});
return api;
};
export const getApiById = async (id: string) => {
return prisma.api.findFirst({
where: { id },
});
};
export const getApiProjectId = async (apiId: string) => {
let api = await prisma.api.findFirst({
where: {
id: apiId,
},
select: {
projectId: true,
},
});
return api?.projectId;
};
export const updateApi = async (
id: string,
data: {
groupId?: string | undefined;
data?: { name: string; path: string; method: RequestMethod };
}
) => {
return prisma.api.update({
where: {
id: id,
},
data: data,
});
};
export const saveApiData = async (id: string, data: ApiData) => {
return prisma.api.update({
where: {
id: id,
},
data: {
data: data,
},
});
};
export const findApisForMock = async (
projectId: string,
method: RequestMethod
) => {
return prisma.api.findMany({
where: {
projectId: projectId,
data: {
is: {
method: method,
},
},
},
select: {
id: true,
data: {
select: {
path: true,
},
},
},
});
};
export const deleteApi = async (id: string) => {
let api = await prisma.api.findFirst({
where: { id },
});
await prisma.api.delete({
where: { id },
});
return api;
};
export const deleteGroup = async (id: string) => {
let group = await prisma.group.findFirst({
where: { id },
});
if (!group) {
return null;
}
let project = await getProjectById(group.projectId);
if (!project) {
return null;
}
let groups: Group[] = [project.root];
let target: Group | undefined;
while (groups.length > 0) {
let g = groups.pop();
if (!g) {
return null;
}
if (g.id === id) {
target = g;
break;
} else {
groups = groups.concat(g.groups);
}
}
if (!target) {
return null;
}
groups = [target];
let groupsToDelete = [target.id];
let apisToDelete: string[] = [];
while (groups.length > 0) {
let g = groups.pop();
if (!g) {
return null;
}
groups = groups.concat(g.groups);
groupsToDelete = groupsToDelete.concat(g.groups.map((item) => item.id));
apisToDelete = apisToDelete.concat(g.apis.map((item) => item.id));
}
await prisma.$transaction([
prisma.group.deleteMany({
where: {
id: {
in: groupsToDelete,
},
},
}),
prisma.api.deleteMany({
where: {
id: {
in: apisToDelete,
},
},
}),
]);
return {
group,
groupsToDelete,
apisToDelete,
};
};
================================================
FILE: app/models/prisma.server.ts
================================================
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient;
declare global {
var __db: PrismaClient | undefined;
}
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
prisma.$connect();
} else {
if (!global.__db) {
global.__db = new PrismaClient();
global.__db.$connect();
}
prisma = global.__db;
}
export { prisma };
================================================
FILE: app/models/project.server.ts
================================================
import { ProjectUserRole, User } from '@prisma/client';
import invariant from 'tiny-invariant';
import { checkRole } from '~/utils';
import { prisma } from './prisma.server';
export const createProject = async (user: User, name: string) => {
let project = await prisma.project.create({
data: {
name: name,
members: {
id: user.id,
role: ProjectUserRole.ADMIN,
},
},
});
await prisma.user.update({
where: {
id: user.id,
},
data: {
projectIds: {
push: project.id,
},
},
});
return project;
};
export const getProjectByIds = async (ids: string[]) => {
let projects = await prisma.project.findMany({
where: {
id: {
in: ids,
},
isDeleted: false,
},
select: {
id: true,
name: true,
members: true,
apis: {
select: { id: true },
},
},
});
return projects;
};
const findProjectById = async (id: string) => {
let project = await prisma.project.findFirst({
where: {
id: id,
isDeleted: false,
},
include: {
groups: {
select: {
id: true,
name: true,
parentId: true,
},
},
apis: {
select: {
id: true,
groupId: true,
data: {
select: {
name: true,
method: true,
path: true,
},
},
},
},
},
});
return project;
};
export type Api = NonNullable<Awaited<ReturnType<typeof findProjectById>>>['apis'][0];
export type PlainGroup = NonNullable<Awaited<ReturnType<typeof findProjectById>>>['groups'][0];
export type Group = PlainGroup & {
apis: Api[];
groups: Group[];
};
export const getProjectById = async (id: string) => {
let project = await findProjectById(id);
if (!project) {
return null;
}
let groupMap = new Map<string, Group>();
let groups = project.groups.map((group) => ({
...group,
apis: new Array(),
groups: new Array(),
}));
let root: Group = {
id: 'root',
name: 'root',
parentId: null,
apis: [],
groups: [],
};
for (let group of groups) {
groupMap.set(group.id, group);
}
for (let group of groups) {
if (group.parentId) {
let parent = groupMap.get(group.parentId);
invariant(parent, 'parent is null');
parent.groups.push(group);
} else {
root.groups.push(group);
}
}
for (let api of project.apis) {
if (api.groupId) {
let group = groupMap.get(api.groupId);
// TODO: add warning log
group?.apis.push(api);
} else {
root.apis.push(api);
}
}
let { apis, groups: _, ...rest } = project;
return {
...rest,
root: root,
};
};
export type Project = NonNullable<Awaited<ReturnType<typeof getProjectById>>>;
export const updateProject = async (
id: string,
data: { name?: string; isDeleted?: boolean }
) => {
return await prisma.project.update({
where: { id: id },
data: data,
});
};
export const addMemberToProject = async (
projectId: string,
userId: string,
role: ProjectUserRole
) => {
return await prisma.$transaction([
prisma.project.update({
where: { id: projectId },
data: {
members: {
push: {
id: userId,
role: role,
},
},
},
}),
prisma.user.update({
where: { id: userId },
data: {
projectIds: {
push: projectId,
},
},
}),
]);
};
export const findProjectMembersById = async (id: string) => {
let project = await prisma.project.findFirst({
where: {
id: id,
},
select: {
id: true,
name: true,
members: true,
},
});
return project;
};
export const transferProject = async (
project: NonNullable<Awaited<ReturnType<typeof findProjectMembersById>>>,
transferUser: User,
currentUser: User
) => {
const transaction = [
prisma.project.update({
where: { id: project.id },
data: {
members: {
deleteMany: {
where: { id: currentUser.id },
},
},
},
}),
prisma.user.update({
where: { id: currentUser.id },
data: {
projectIds: currentUser.projectIds.filter((id) => id !== project.id),
},
}),
];
if (project.members.every((member) => member.id !== transferUser.id)) {
transaction.push(
prisma.project.update({
where: { id: project.id },
data: {
members: {
push: {
id: transferUser.id,
role: ProjectUserRole.ADMIN,
},
},
},
})
);
} else {
transaction.push(
prisma.project.update({
where: { id: project.id },
data: {
members: {
updateMany: {
where: { id: transferUser.id },
data: {
role: ProjectUserRole.ADMIN,
}
},
},
},
})
);
}
if (!transferUser.projectIds.includes(project.id)) {
transaction.push(
prisma.user.update({
where: { id: transferUser.id },
data: {
projectIds: {
push: project.id,
},
},
})
);
}
return await prisma.$transaction(transaction);
};
export const checkAuthority = async (
userId: string,
projectId: string,
requiredRole: ProjectUserRole
) => {
let project = await prisma.project.findFirst({
where: {
id: projectId,
},
select: {
id: true,
members: true,
},
});
if (!project) {
return false;
}
let memberRole = project?.members.find(
(member) => member.id === userId
)?.role;
if (!memberRole) {
return false;
}
return checkRole(memberRole, requiredRole);
};
export const changeProjectRole = async (
projectId: string,
userId: string,
role: ProjectUserRole
) => {
return await prisma.project.update({
where: {
id: projectId,
},
data: {
members: {
updateMany: {
where: {
id: userId,
},
data: {
role: role,
},
},
},
},
});
};
export const changeProjectMembers = async (
projectId: string,
userId: string
) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
}
})
if (!user) {
return null;
}
return await prisma.$transaction([prisma.user.update({
where: {
id: userId,
},
data: {
projectIds: user.projectIds.filter(id => id !== projectId)
},
}), prisma.project.update({
where: {
id: projectId,
},
data: {
members: {
deleteMany: {
where: {
id: userId,
}
},
},
},
})
]);
};
================================================
FILE: app/models/type.ts
================================================
import {
ParamType,
Prisma,
ProjectUserRole,
RequestMethod,
} from "@prisma/client";
export const JsonNodeType = [
ParamType.OBJECT,
ParamType.ARRAY,
ParamType.STRING,
ParamType.FLOAT,
ParamType.INT,
ParamType.BOOLEAN,
] as const;
export const RequestMethods = [
RequestMethod.GET,
RequestMethod.POST,
RequestMethod.PUT,
RequestMethod.PATCH,
RequestMethod.DELETE,
] as const;
export interface JsonNode {
name: string;
isRequired: boolean;
type: Exclude<ParamType, "FILE">;
mock?: string;
example?: string;
description?: string;
children: JsonNode[];
arrayElem?: JsonNode;
}
export const ProjectUserRoles = [
ProjectUserRole.READ,
ProjectUserRole.WRITE,
ProjectUserRole.ADMIN,
] as const;
================================================
FILE: app/models/user.server.ts
================================================
import { User } from "@prisma/client";
import { prisma } from "./prisma.server";
import bcrypt from "bcryptjs";
export type { User };
export async function getUserById(id: User["id"]) {
return prisma.user.findUnique({ where: { id } });
}
export async function getUserByName(name: User["name"]) {
return prisma.user.findFirst({ where: { name } });
}
export async function getUserByEmail(email: User["email"]) {
return prisma.user.findUnique({ where: { email } });
}
export async function getUserInfoByIds(ids: string[]) {
return prisma.user.findMany({
where: {
id: {
in: ids,
},
},
select: {
id: true,
name: true,
email: true,
},
});
}
export async function createUser(
email: User["email"],
password: User["password"],
name: User["name"]
) {
const hashedPassword = await bcrypt.hash(password, 10);
return prisma.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
}
export async function verifyLogin(
email: User["email"],
password: User["password"]
) {
const userWithPassword = await prisma.user.findUnique({
where: { email },
});
if (!userWithPassword || !userWithPassword.password) {
return null;
}
const isValid = await bcrypt.compare(password, userWithPassword.password);
if (!isValid) {
return null;
}
const { password: _password, ...userWithoutPassword } = userWithPassword;
return userWithoutPassword;
}
export const updateUserInfo = async (
id: string,
{
name,
}: {
name?: string;
}
) => {
const result = await prisma.user.update({
where: { id: id },
data: {
name: name,
},
});
return result;
};
export const updatePassword = async (id: string, password: string) => {
const hashedPassword = await bcrypt.hash(password, 10);
return await prisma.user.update({
where: { id },
data: { password: hashedPassword },
});
};
================================================
FILE: app/root.tsx
================================================
// root.tsx
import {
ChakraProvider,
cookieStorageManagerSSR,
localStorageManager,
useConst,
} from "@chakra-ui/react";
import { withEmotionCache } from "@emotion/react";
import { json, LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node"; // Depends on the runtime you choose
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import React, { useContext, useEffect } from "react";
import { ClientStyleContext, ServerStyleContext } from "./context";
import { getUser } from "./session.server";
export const loader = async ({ request }: LoaderArgs) => {
let user = await getUser(request);
return json({
cookies: request.headers.get("cookie") ?? "",
user: { ...user },
url: request.url,
});
};
export const meta: MetaFunction = () => ({
charset: "utf-8",
title: "Api Annie",
viewport: "width=device-width,initial-scale=1",
});
export let links: LinksFunction = () => {
return [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com" },
{
rel: "icon",
href: "/favicon.ico",
type: "image/png",
},
];
};
interface DocumentProps {
children: React.ReactNode;
}
const Document = withEmotionCache(
({ children }: DocumentProps, emotionCache) => {
const serverStyleData = useContext(ServerStyleContext);
const clientStyleData = useContext(ClientStyleContext);
// Only executed on client
useEffect(() => {
// re-link sheet container
emotionCache.sheet.container = document.head;
// re-inject tags
const tags = emotionCache.sheet.tags;
emotionCache.sheet.flush();
tags.forEach((tag) => {
(emotionCache.sheet as any)._insertTag(tag);
});
// reset cache to reapply global styles
clientStyleData?.reset();
}, []);
return (
<html lang="en">
<head>
<Meta />
<Links />
{serverStyleData?.map(({ key, ids, css }) => (
<style
key={key}
data-emotion={`${key} ${ids.join(" ")}`}
dangerouslySetInnerHTML={{ __html: css }}
/>
))}
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
);
export default function App() {
const { cookies } = useLoaderData<typeof loader>();
const cookieManager = useConst(cookieStorageManagerSSR(cookies));
return (
<Document>
<ChakraProvider
colorModeManager={
typeof cookies === "string" ? cookieManager : localStorageManager
}
>
<Outlet />
</ChakraProvider>
</Document>
);
}
================================================
FILE: app/routes/home/..lib/ColorModeButton.tsx
================================================
import { MoonIcon, SunIcon } from "@chakra-ui/icons";
import { Button, ButtonProps, useColorMode } from "@chakra-ui/react";
export default function ColorModeButton(props: ButtonProps) {
const { colorMode, toggleColorMode } = useColorMode();
return (
<Button {...props} onClick={toggleColorMode}>
{colorMode === "light" ? <MoonIcon /> : <SunIcon />}
</Button>
);
}
================================================
FILE: app/routes/home/..lib/Layout.tsx
================================================
import {
ChevronDownIcon,
ChevronRightIcon,
CloseIcon,
HamburgerIcon,
} from "@chakra-ui/icons";
import {
Box,
Button,
Collapse,
Container,
Flex,
Grid,
Icon,
IconButton,
Image,
Link,
Popover,
PopoverContent,
PopoverTrigger,
Stack,
Text,
useColorMode,
useColorModeValue,
useDisclosure,
VisuallyHidden,
} from "@chakra-ui/react";
import { Link as RemixLink } from "@remix-run/react";
import { PropsWithChildren, ReactNode } from "react";
import { FaGithub, FaTwitter } from "react-icons/fa";
import logo from "~/images/logo_banner_sm.png";
import { useOptionalUser } from "~/utils";
import ColorModeButton from "./ColorModeButton";
import UserMenuButton from "./UserMenuButton";
export default function Layout({ children }: PropsWithChildren) {
return (
<Grid templateRows="73px 1fr 73px" minH="100vh">
<Header />
{children}
<Footer />
</Grid>
);
}
function Header() {
const { isOpen, onToggle } = useDisclosure();
const { colorMode, toggleColorMode } = useColorMode();
const user = useOptionalUser();
return (
<Box
borderBottom={1}
borderStyle={"solid"}
borderColor={useColorModeValue("gray.200", "gray.900")}
>
<Flex
bg={useColorModeValue("white", "gray.800")}
color={useColorModeValue("gray.600", "white")}
minH={"60px"}
py={{ base: 4 }}
px={{ base: 4 }}
align={"center"}
margin="0 auto"
maxWidth={1200}
>
<Flex
flex={{ base: 1, md: "auto" }}
ml={{ base: -2 }}
display={{ base: "flex", md: "none" }}
>
<IconButton
onClick={onToggle}
icon={
isOpen ? <CloseIcon w={3} h={3} /> : <HamburgerIcon w={5} h={5} />
}
variant={"ghost"}
aria-label={"Toggle Navigation"}
/>
</Flex>
<Flex flex={{ base: 1 }} justify={{ base: "center", md: "start" }}>
<RemixLink to="/">
<Image
src={logo}
display={{ base: "none", md: "flex" }}
height="32px"
/>
</RemixLink>
<Flex display={{ base: "none", md: "flex" }} ml={10}>
<DesktopNav />
</Flex>
</Flex>
<Stack
flex={{ base: 1, md: 0 }}
justify={"flex-end"}
direction={"row"}
spacing={6}
>
<Button variant="link">
<Link isExternal href={"https://github.com/apiannie/apiannie"}>
<Icon as={FaGithub} w={5} h={5} />
</Link>
</Button>
<ColorModeButton />
{user ? (
<UserMenuButton
avatar={user.avatar || undefined}
name={user.name}
/>
) : (
<GuestMenuButtons />
)}
</Stack>
</Flex>
<Collapse in={isOpen} animateOpacity>
<MobileNav />
</Collapse>
</Box>
);
}
function GuestMenuButtons() {
return (
<>
<Button
fontSize={"sm"}
fontWeight={400}
variant={"link"}
as={RemixLink}
to="/home/signin"
>
Sign In
</Button>
<Button
display={{ base: "none", md: "inline-flex" }}
fontSize={"sm"}
fontWeight={600}
colorScheme="teal"
as={RemixLink}
to="/home/signup"
>
Sign up
</Button>
</>
);
}
const DesktopNav = () => {
const linkColor = useColorModeValue("gray.600", "gray.200");
const linkHoverColor = useColorModeValue("gray.800", "white");
const popoverContentBgColor = useColorModeValue("white", "gray.800");
return (
<Stack direction={"row"} spacing={4}>
{NAV_ITEMS.map((navItem) => (
<Box key={navItem.label}>
<Popover trigger={"hover"} placement={"bottom-start"}>
<PopoverTrigger>
{/* <Link to="#">{navItem.label}</Link> */}
</PopoverTrigger>
{navItem.children && (
<PopoverContent
border={0}
boxShadow={"xl"}
bg={popoverContentBgColor}
p={4}
rounded={"xl"}
minW={"sm"}
>
<Stack>
{navItem.children.map((child) => (
<DesktopSubNav key={child.label} {...child} />
))}
</Stack>
</PopoverContent>
)}
</Popover>
</Box>
))}
</Stack>
);
};
const DesktopSubNav = ({ label, href, subLabel }: NavItem) => {
return (
<Link
role={"group"}
display={"block"}
p={2}
rounded={"md"}
_hover={{ bg: useColorModeValue("pink.50", "gray.900") }}
>
<Stack direction={"row"} align={"center"}>
<Box>
<Text
transition={"all .3s ease"}
_groupHover={{ color: "pink.400" }}
fontWeight={500}
>
{label}
</Text>
<Text fontSize={"sm"}>{subLabel}</Text>
</Box>
<Flex
transition={"all .3s ease"}
transform={"translateX(-10px)"}
opacity={0}
_groupHover={{ opacity: "100%", transform: "translateX(0)" }}
justify={"flex-end"}
align={"center"}
flex={1}
>
<Icon color={"pink.400"} w={5} h={5} as={ChevronRightIcon} />
</Flex>
</Stack>
</Link>
);
};
const MobileNav = () => {
return (
<Stack
bg={useColorModeValue("white", "gray.800")}
p={4}
display={{ md: "none" }}
>
{NAV_ITEMS.map((navItem) => (
<MobileNavItem key={navItem.label} {...navItem} />
))}
</Stack>
);
};
const MobileNavItem = ({ label, children, href }: NavItem) => {
const { isOpen, onToggle } = useDisclosure();
return (
<Stack spacing={4} onClick={children && onToggle}>
<Flex
py={2}
as={Link}
to={href ?? "#"}
justify={"space-between"}
align={"center"}
_hover={{
textDecoration: "none",
}}
>
<Text
fontWeight={600}
color={useColorModeValue("gray.600", "gray.200")}
>
{label}
</Text>
{children && (
<Icon
as={ChevronDownIcon}
transition={"all .25s ease-in-out"}
transform={isOpen ? "rotate(180deg)" : ""}
w={6}
h={6}
/>
)}
</Flex>
<Collapse in={isOpen} animateOpacity style={{ marginTop: "0!important" }}>
<Stack
mt={2}
pl={4}
borderLeft={1}
borderStyle={"solid"}
borderColor={useColorModeValue("gray.200", "gray.700")}
align={"start"}
>
{children &&
children.map((child) => (
<RemixLink to="#" key={child.label}>
{child.label}
</RemixLink>
))}
</Stack>
</Collapse>
</Stack>
);
};
interface NavItem {
label: string;
subLabel?: string;
children?: Array<NavItem>;
href?: string;
}
const NAV_ITEMS: Array<NavItem> = [];
const SocialButton = ({
children,
label,
href,
}: {
children: ReactNode;
label: string;
href: string;
}) => {
return (
<Button
bg={useColorModeValue("blackAlpha.100", "whiteAlpha.100")}
rounded={"full"}
cursor={"pointer"}
as={Link}
href={href}
isExternal
display={"inline-flex"}
alignItems={"center"}
justifyContent={"center"}
transition={"background 0.3s ease"}
_hover={{
bg: useColorModeValue("blackAlpha.200", "whiteAlpha.200"),
}}
>
<VisuallyHidden>{label}</VisuallyHidden>
{children}
</Button>
);
};
function Footer() {
return (
<Box
bg={useColorModeValue("gray.50", "gray.900")}
color={useColorModeValue("gray.700", "gray.200")}
>
<Box
borderTopWidth={1}
borderStyle={"solid"}
borderColor={useColorModeValue("gray.200", "gray.700")}
>
<Container
as={Stack}
maxW={"6xl"}
py={4}
direction={{ base: "column", md: "row" }}
spacing={4}
justify={{ base: "center", md: "space-between" }}
align={{ base: "center", md: "center" }}
>
<Text>© 2022 Api Annie. All rights reserved</Text>
<Stack direction={"row"} spacing={6}>
<SocialButton
label={"Github"}
href={"https://github.com/apiannie/apiannie"}
>
<Icon as={FaGithub} w={4} h={4} />
</SocialButton>
{/* <SocialButton label={"Twitter"} href={"#"}>
<FaTwitter />
</SocialButton> */}
</Stack>
</Container>
</Box>
</Box>
);
}
================================================
FILE: app/routes/home/..lib/NotificationButton.tsx
================================================
import {
Button,
ButtonProps,
Center,
Icon,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
} from "@chakra-ui/react";
import React from "react";
import { FiBell } from "react-icons/fi";
const NotificationButton = ({ ...props }: ButtonProps) => {
return (
<Popover>
<PopoverTrigger>
<Button {...props}>
<Icon aria-label="open menu" as={FiBell} />
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
{/* <PopoverCloseButton /> */}
{/* <PopoverHeader>Confirmation!</PopoverHeader> */}
<PopoverBody>
<Center minH={48} color="gray.400">
No notifications
</Center>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default NotificationButton;
================================================
FILE: app/routes/home/..lib/UserMenuButton.tsx
================================================
import {
Avatar,
AvatarProps,
Box,
Button,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
Text,
} from "@chakra-ui/react";
import { Link, useSubmit } from "@remix-run/react";
export default function UserMenuButton({
size,
avatar,
name,
...props
}: Omit<AvatarProps, "avatar"> & {
avatar?: string;
name: string;
}) {
const submit = useSubmit();
return (
<Menu>
<MenuButton
as={Button}
rounded={"full"}
variant={"link"}
cursor={"pointer"}
minW={0}
>
<Avatar size={size || "sm"} src={avatar} {...props}></Avatar>
</MenuButton>
<MenuList>
<Box px={3} fontSize="sm">
<Text>Signed in as</Text>
<Text fontWeight={"bold"}>{name}</Text>
</Box>
<MenuDivider />
<MenuItem as={Link} to="/home/settings">
Settings
</MenuItem>
<MenuDivider />
<MenuItem
onClick={(e) =>
submit(null, { method: "post", action: "/home/logout" })
}
>
Sign out
</MenuItem>
</MenuList>
</Menu>
);
}
================================================
FILE: app/routes/home/logout.tsx
================================================
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { logout } from "~/session.server";
export async function action({ request }: ActionArgs) {
return logout(request);
}
export async function loader({ request }: LoaderArgs) {
return logout(request);
}
================================================
FILE: app/routes/home/settings.tsx
================================================
import {
Flex,
Heading,
Text,
Stack,
useColorModeValue,
Box,
Container,
Center,
useToast,
} from "@chakra-ui/react";
import { ActionArgs } from "@remix-run/node";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { z } from "zod";
import {
getUserById,
updatePassword,
updateUserInfo,
} from "~/models/user.server";
import { getUser, requireUser, requireUserId } from "~/session.server";
import { FormHInput, FormSubmitButton, Header } from "~/ui";
import { httpResponse, useUser } from "~/utils";
import Layout from "./..lib/Layout";
import bcrypt from "bcryptjs";
import { json } from "remix-utils";
import { useActionData, useSubmit, useTransition } from "@remix-run/react";
import { useEffect } from "react";
export const action = async ({ request }: ActionArgs) => {
let userId = await requireUserId(request);
let formData = await request.formData();
let action = formData.get("_action");
switch (action) {
case "updateInfo": {
let result = await accountValidator.validate(formData);
if (result.error) {
return validationError(result.error);
}
await updateUserInfo(userId, result.data);
break;
}
case "changePassword": {
let result = await passwordValidator.validate(formData);
if (result.error) {
return validationError(result.error);
}
let { passwordCurrent, passwordConfirm, passwordNew } = result.data;
if (passwordNew !== passwordConfirm) {
return validationError(
{ fieldErrors: { passwordConfirm: "Password does not match" } },
undefined,
{ status: 400 }
);
}
let user = await getUserById(userId);
if (!user) {
return httpResponse.BadRequest;
}
const isValid = await bcrypt.compare(passwordCurrent, user.password);
if (!isValid) {
return validationError(
{
fieldErrors: {
passwordCurrent: "Password does not match our record",
},
},
undefined,
{ status: 400 }
);
}
await updatePassword(userId, passwordNew);
break;
}
default:
return httpResponse.BadRequest;
}
return json({ action: action });
};
const accountValidator = withZod(
z.object({
name: z
.string()
.trim()
.min(1, { message: "Please input your name" })
.min(2, { message: "Name should contain at least 2 characters" }),
})
);
const passwordValidator = withZod(
z.object({
passwordCurrent: z
.string()
.min(1, { message: "Please input your password" }),
passwordNew: z
.string()
.min(1, { message: "Please input your new password" })
.min(8, { message: "Length of password should be at least 8" })
.max(32, "Length of password should not exceed 32"),
passwordConfirm: z
.string()
.min(1, { message: "Please input your confirm password" }),
})
);
export default function Settings() {
const user = useUser();
const bg = useColorModeValue("gray.100", "gray.700");
const bgBW = useColorModeValue("white", "gray.900");
const labelWidth = 280;
const toast = useToast();
let transition = useTransition();
useEffect(() => {
if (transition.state === "loading") {
let action = transition.submission?.formData?.get("_action");
if (action === "updateInfo") {
toast({
title: "Account updated.",
status: "success",
position: "top",
});
} else if (action === "changePassword") {
toast({
title: "Password changed.",
status: "success",
position: "top",
});
}
}
}, [transition.state]);
return (
<Layout>
<Container mt={8} maxW="container.lg">
<Header>Account</Header>
<Box
bg={bg}
pr={labelWidth}
pt={8}
pb={12}
as={ValidatedForm}
validator={accountValidator}
replace
method="patch"
>
<FormHInput
container={{ mt: 4 }}
label="Email"
name="email"
p={2}
labelWidth={labelWidth}
as={Text}
w="full"
>
{user.email}
</FormHInput>
<FormHInput
container={{ mt: 4 }}
label="Name"
name="name"
labelWidth={labelWidth}
bg={bgBW}
defaultValue={user.name}
/>
<Center mt={4} pl={labelWidth}>
<FormSubmitButton
name="_action"
value="updateInfo"
w={160}
colorScheme={"blue"}
>
Save
</FormSubmitButton>
</Center>
</Box>
<Header mt={12}>Password</Header>
<Box
bg={bg}
pr={labelWidth}
pt={8}
pb={12}
as={ValidatedForm}
validator={passwordValidator}
replace
method="patch"
resetAfterSubmit
>
<FormHInput
container={{ mt: 4 }}
label="Current password"
name="passwordCurrent"
labelWidth={labelWidth}
bg={bgBW}
type="password"
/>
<FormHInput
container={{ mt: 4 }}
label="New password"
name="passwordNew"
labelWidth={labelWidth}
bg={bgBW}
type="password"
/>
<FormHInput
container={{ mt: 4 }}
label="Confirm password"
name="passwordConfirm"
labelWidth={labelWidth}
bg={bgBW}
type="password"
/>
<Center mt={4} pl={labelWidth}>
<FormSubmitButton
name="_action"
value="changePassword"
w={160}
colorScheme={"blue"}
>
Save
</FormSubmitButton>
</Center>
</Box>
</Container>
</Layout>
);
}
================================================
FILE: app/routes/home/signin.tsx
================================================
import {
Box,
Button,
Checkbox,
Flex,
Heading,
InputRightElement,
Stack,
Text,
useColorModeValue,
} from "@chakra-ui/react";
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import {
ActionArgs,
json,
LoaderArgs,
MetaFunction,
redirect,
} from "@remix-run/node";
import { Link, useActionData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { useEffect, useRef, useState } from "react";
import { ValidatedForm, validationError } from "remix-validated-form";
import { z } from "zod";
import { verifyLogin } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import FormInput from "~/ui/Form/FormInput";
import FormSubmitButton from "~/ui/Form/FormSubmitButton";
import { safeRedirect } from "~/utils";
import Layout from "./..lib/Layout";
export async function loader({ request }: LoaderArgs) {
const userId = await getUserId(request);
if (userId) return redirect("/projects");
return json({});
}
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const result = await validator.validate(formData);
if (result.error) {
return validationError(result.error);
}
const { email, password, remember } = result.data;
const redirectTo = safeRedirect(formData.get("redirectTo"), "/projects");
const user = await verifyLogin(email, password);
if (!user) {
return validationError(
{ fieldErrors: { password: "Incorrect password" } },
result.submittedData,
{ status: 401 }
);
}
return createUserSession({
request,
userId: user.id,
remember: !!remember,
redirectTo,
});
}
export const meta: MetaFunction = () => {
return {
title: "Login | Api Annie",
};
};
export const validator = withZod(
z.object({
email: z
.string()
.trim()
.min(1, { message: "Please input your email" })
.email(),
password: z.string().min(1, "Please input your password"),
remember: z.string().optional(),
})
);
export default function Login() {
const [showPassword, setShowPassword] = useState(false);
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const actionData = useActionData();
useEffect(() => {
if (actionData?.fieldErrors?.email) {
emailRef.current?.focus();
} else if (actionData?.fieldErrors?.password) {
passwordRef.current?.focus();
}
}, [actionData]);
return (
<Layout>
<Flex minH={"100vh"} bg={useColorModeValue("gray.50", "gray.800")}>
<Stack spacing={8} mx={"auto"} width="440px" maxW={"lg"} py={20} px={6}>
<Stack align={"center"}>
<Heading fontSize={"4xl"}>Sign in to your account</Heading>
<Text fontSize={"lg"} color={"gray.600"}>
to enjoy all of our cool <Link to="#">features</Link> ✌️
</Text>
</Stack>
<Box
rounded={"lg"}
bg={useColorModeValue("white", "gray.700")}
boxShadow={"lg"}
p={8}
>
<ValidatedForm validator={validator} method="post">
<Stack spacing={4}>
<FormInput label="Email address" name="email" ref={emailRef} />
<FormInput
label="Password"
name="password"
type={showPassword ? "text" : "password"}
ref={passwordRef}
>
<InputRightElement h={"full"}>
<Button
variant={"ghost"}
onClick={() =>
setShowPassword((showPassword) => !showPassword)
}
>
{showPassword ? <ViewIcon /> : <ViewOffIcon />}
</Button>
</InputRightElement>
</FormInput>
<Stack spacing={10}>
<Stack
direction={{ base: "column", sm: "row" }}
align={"start"}
justify={"space-between"}
>
<Checkbox name="remember" value="on">
Remember me
</Checkbox>
</Stack>
<FormSubmitButton type="submit" colorScheme="teal">
Sign in
</FormSubmitButton>
</Stack>
<Stack pt={6}>
<Text align={"center"}>
New to Api Annie?{" "}
<Link to="/home/signup" color={"blue.400"}>
Sign up
</Link>
</Text>
</Stack>
</Stack>
</ValidatedForm>
</Box>
</Stack>
</Flex>
</Layout>
);
}
================================================
FILE: app/routes/home/signup.tsx
================================================
import {
Box,
Flex,
Heading,
Stack,
Text,
useColorModeValue,
} from "@chakra-ui/react";
import { ActionArgs, json, LoaderArgs, redirect } from "@remix-run/node";
import { Link, useActionData } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { ValidatedForm, validationError } from "remix-validated-form";
import { z } from "zod";
import { createUser, getUserByEmail } from "~/models/user.server";
import { createUserSession, getUserId } from "~/session.server";
import FormInput from "~/ui/Form/FormInput";
import FormSubmitButton from "~/ui/Form/FormSubmitButton";
import { safeRedirect } from "~/utils";
import Layout from "./..lib/Layout";
export async function loader({ request }: LoaderArgs) {
const userId = await getUserId(request);
if (userId) return redirect("/workspaces");
return json({});
}
export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const result = await validator.validate(formData);
if (result.error) {
return validationError(result.error);
}
const { name, email, password, passwordConfirm } = result.data;
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
if (password !== passwordConfirm) {
return validationError(
{ fieldErrors: { passwordConfirm: "Password does not match" } },
undefined,
{ status: 400 }
);
}
const existingUser = await getUserByEmail(email);
if (existingUser) {
return validationError(
{ fieldErrors: { email: "A user already exists with this email" } },
undefined,
{ status: 400 }
);
}
const user = await createUser(email, password, name);
return createUserSession({
request,
userId: user.id,
remember: false,
redirectTo,
});
}
export const validator = withZod(
z
.object({
name: z
.string()
.trim()
.min(1, { message: "Please input your name" })
.min(2, { message: "Name should contain at least 2 characters" }),
email: z
.string()
.trim()
.min(1, { message: "Please input your email" })
.max(128, { message: "Email is too long (over 128)" })
.email(),
password: z
.string()
.min(1, { message: "Please input your password" })
.min(8, { message: "Length of password should be at least 8" })
.max(32, "Length of password should not exceed 32"),
passwordConfirm: z
.string()
.min(1, { message: "Please input your confirm password" }),
})
.refine(({ password, passwordConfirm }) => password === passwordConfirm, {
path: ["passwordConfirm"],
message: "Passwords must match",
})
);
export default function SignUp() {
const actionData = useActionData<typeof action>();
return (
<Layout>
<Flex
minH={"100vh"}
justify={"center"}
bg={useColorModeValue("gray.50", "gray.800")}
>
<Stack width="440px" spacing={8} mx={"auto"} maxW={"lg"} py={12} px={6}>
<Stack align={"center"}>
<Heading fontSize={"4xl"} textAlign={"center"}>
Sign up
</Heading>
<Text fontSize={"lg"} color={"gray.600"}>
to enjoy all of our cool <Link to="#">features</Link> ✌️
</Text>
</Stack>
<Box
rounded={"lg"}
bg={useColorModeValue("white", "gray.700")}
boxShadow={"lg"}
p={8}
>
<Stack spacing={4}>
<ValidatedForm validator={validator} method="post">
<FormInput name="name" label="Name" type="text" />
<FormInput name="email" label="Email" type="email" />
<FormInput name="password" label="Password" type="password" />
<FormInput
name="passwordConfirm"
label="Confirm Password"
type="password"
/>
<Stack spacing={10} pt={2}>
<FormSubmitButton size="lg" colorScheme="teal">
Sign up
</FormSubmitButton>
</Stack>
</ValidatedForm>
<Stack pt={6}>
<Text align={"center"}>
Already a user?{" "}
<Link to="/home/signin" color={"blue.400"}>
Sign in
</Link>
</Text>
</Stack>
</Stack>
</Box>
</Stack>
</Flex>
</Layout>
);
}
================================================
FILE: app/routes/index.tsx
================================================
import { Box, Button, Container, Heading, Stack, Text } from "@chakra-ui/react";
import { json, LoaderArgs, redirect } from "@remix-run/node";
import { Link as RemixLink } from "@remix-run/react";
import { getUserId } from "~/session.server";
import Layout from "./home/..lib/Layout";
export function links() {
return [
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Caveat:wght@700&display=swap",
},
];
}
export async function loader({ request }: LoaderArgs) {
const userId = await getUserId(request);
if (userId) return redirect("/projects");
return json({});
}
export default function () {
return (
<Layout>
<Container maxW={"4xl"}>
<Stack
as={Box}
textAlign={"center"}
spacing={{ base: 8, md: 14 }}
py={{ base: 20, md: 36 }}
>
<Heading
fontWeight={600}
fontSize={{ base: "2xl", sm: "4xl", md: "6xl" }}
lineHeight={"110%"}
>
Lightweight platform
<br />
<Text
fontSize={{ base: "2xl", sm: "4xl", md: "6xl" }}
as={"span"}
color={"teal.400"}
>
for API development
</Text>
</Heading>
<Text fontSize={{ base: "1xl" }} color={"gray.500"}>
API documentation, debuging, mocking and testing tool
<br />
for frontend developers, backend engineers and QAs
</Text>
<Text color={"gray.500"}></Text>
<Stack
direction={"column"}
spacing={3}
align={"center"}
alignSelf={"center"}
position={"relative"}
>
<RemixLink to="/home/signup">
<Button colorScheme={"teal"} px={6}>
Get Started
</Button>
</RemixLink>
{/* <Button variant={"link"} colorScheme={"blue"} size={"sm"}>
Learn more
</Button> */}
</Stack>
</Stack>
</Container>
</Layout>
);
}
================================================
FILE: app/routes/mock/$projectId.$.tsx
================================================
import { RequestMethod } from "@prisma/client";
import { ActionArgs, json, LoaderArgs, Response } from "@remix-run/node";
import { cors } from "remix-utils";
import invariant from "tiny-invariant";
import { findApisForMock, getApiById } from "~/models/api.server";
import { JsonNode } from "~/models/type";
import { httpResponse } from "~/utils";
import { mockJson } from "~/utils/mock";
export const loader = (args: LoaderArgs) => {
return action(args);
};
export const action = async ({ params, request }: ActionArgs) => {
if (request.method === RequestMethod.OPTIONS) {
return await cors(request, new Response());
}
let projectId = params.projectId as string;
let path = "/" + params["*"];
let method = request.method as RequestMethod;
let apis = await findApisForMock(projectId, method);
let pathParam = findPathForRule(
path,
apis.map((api) => api.data.path)
);
if (!pathParam) {
return httpResponse.NotFound;
}
let rule = pathParam.rule;
let apiFound = apis.find((api) => api.data.path === rule);
invariant(apiFound);
let api = await getApiById(apiFound.id);
invariant(api);
let response: JsonNode | undefined = (api.data.response as any)?.["200"];
let mocked = mockJson(response);
return await cors(request, json(mocked));
};
function findPathForRule(path: string, rules: string[]) {
let pathParams = rules
.map((rule) => matchApi(path, rule))
.filter((item): item is NonNullable<typeof item> => !!item);
pathParams.sort((a, b) => b.weight - a.weight);
return pathParams.length > 0 ? pathParams[0] : undefined;
}
/**
*
* @param {*} apiPath /user/tom
* @param {*} apiRule /user/:username
*/
function matchApi(apiPath: string, apiRule: string) {
let apiRules = apiRule.split("/");
let apiPaths = apiPath.split("/");
let pathParams = {
rule: apiRule,
weight: 0,
params: {} as { [key in string]: string },
};
if (apiPaths.length !== apiRules.length) {
return null;
}
for (let i = 0; i < apiRules.length; i++) {
if (apiRules[i]) {
apiRules[i] = apiRules[i].trim();
} else {
continue;
}
if (
apiRules[i].length > 2 &&
apiRules[i][0] === "{" &&
apiRules[i][apiRules[i].length - 1] === "}"
) {
pathParams.params[apiRules[i].substring(1, apiRules[i].length - 1)] =
apiPaths[i];
} else if (
apiRules[i].length > 2 &&
apiRules[i].indexOf("{") > -1 &&
apiRules[i].indexOf("}") > -1
) {
let params = [] as string[];
apiRules[i] = apiRules[i].replace(/\{(.+?)\}/g, function (src, match) {
params.push(match);
return "([^\\/\\s]+)";
});
let regexp = new RegExp(apiRules[i]);
if (!regexp.test(apiPaths[i])) {
return null;
}
let matchs = apiPaths[i].match(regexp) || [];
params.forEach((item, index) => {
pathParams.params[item] = matchs[index + 1];
});
} else {
if (apiRules[i] !== apiPaths[i]) {
return null;
} else {
pathParams.weight++;
}
}
}
return pathParams;
}
================================================
FILE: app/routes/projects/$projectId/activities.tsx
================================================
export default function Activities() {
return "Activities";
}
================================================
FILE: app/routes/projects/$projectId/apis/..api.tsx
================================================
import {
Box,
BoxProps,
Center,
Flex,
Grid,
Heading,
Icon,
Link,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react";
import {
ParamType,
RequestBodyRaw,
RequestBodyType,
RequestParam,
} from "@prisma/client";
import { useLoaderData, useParams } from "@remix-run/react";
import React, { useEffect, useState } from "react";
import { loader } from "./details.$apiId";
import { Header } from "~/ui";
import { useMethodTag } from "../apis";
import { BsFillCaretDownFill, BsFillCaretRightFill } from "react-icons/bs";
import { JsonNode } from "~/models/type";
import { methodContainsBody, parsePath, useUrl } from "~/utils";
import invariant from "tiny-invariant";
const Api = () => {
let { projectId } = useParams();
let url = useUrl();
invariant(projectId);
const bg = useColorModeValue("gray.100", "gray.700");
const fontColor = useColorModeValue("gray.600", "whiteAlpha.700");
const labelWidth = "120px";
const { api } = useLoaderData<typeof loader>();
let { text, color } = useMethodTag(api.data.method);
const method = api.data.method;
const requestHasBody = methodContainsBody(method);
const bodyJson = api.data.bodyJson as JsonNode | null;
const bodyRaw = api.data.bodyRaw as RequestBodyRaw | null;
const response200 = (api.data.response as any)?.["200"] as JsonNode | null;
const parsedPath = parsePath(api.data.path);
const pathParams = parsedPath.params.map<RequestParam>((param) => {
let elem = api.data.pathParams.find((obj) => obj.name === param);
return {
name: param,
example: elem?.example || null,
description: elem?.description || null,
isRequired: elem?.isRequired || false,
type: elem?.type || ParamType.STRING,
};
});
return (
<Box key={`${api.id}-${api.updatedAt}`} position={"relative"} p={2} pb={10}>
<Header>General</Header>
<Box p={4}>
<Flex>
<Text as="strong" width={labelWidth}>
Name:
</Text>
<Text color={fontColor}>{api.data.name}</Text>
</Flex>
<Flex mt={5}>
<Text as="strong" width={labelWidth}>
Path:
</Text>
<Flex color={fontColor}>
<Box
fontWeight={700}
bg={color}
px={2}
mr={3}
borderRadius={3}
color={"white"}
flexBasis="40px"
>
{text}
</Box>
{api.data.path}
</Flex>
</Flex>
{pathParams.length > 0 && (
<Flex mt={2}>
<Text as="strong" width={labelWidth}>
Params:
</Text>
<ParamTable
flexGrow={1}
size="sm"
noPadding={true}
defaultValue={pathParams}
/>
</Flex>
)}
<Flex mt={5}>
<Text as="strong" width={labelWidth}>
Mock Server:
</Text>
<Link
color={useColorModeValue("blue.700", "blue.200")}
isExternal
href={`/mock/${projectId}${api.data.path}`}
>
{`${url.origin}/mock/${projectId}${api.data.path}`}
</Link>
</Flex>
{api.data.description && (
<Flex mt={5}>
<Text as="strong" width={labelWidth}>
Description:
</Text>
{api.data.description}
</Flex>
)}
</Box>
<Header mt={12}>Request</Header>
<Box p={4}>
<Text as="strong" size={"sm"} mb={5}>
Headers:
</Text>
{api.data.headers && api.data.headers.length > 0 ? (
<ParamTable
mt={1}
borderWidth="1px"
defaultValue={api.data.headers}
/>
) : (
<Box mt={1} borderWidth={1} borderRadius={5} py={6}>
<Text color="gray" textAlign={"center"}>
No Data
</Text>
</Box>
)}
</Box>
{api.data.queryParams && api.data.queryParams.length > 0 && (
<Box mt={3} p={4}>
<Text as={"strong"} size={"sm"} mb={5}>
Query:
</Text>
<ParamTable
mt={1}
borderWidth="1px"
defaultValue={api.data.queryParams}
/>
</Box>
)}
{requestHasBody && (
<Box mt={3} p={4}>
<Text as="strong">Body ({api.data.bodyType}):</Text>
<BodyEditor
mt={1}
type={api.data.bodyType}
bodyJson={bodyJson || undefined}
bodyForm={api.data.bodyForm}
bodyRaw={bodyRaw || undefined}
borderWidth="1px"
/>
</Box>
)}
<Header mt={12}>Response</Header>
<Box p={4}>
<Box borderWidth={"1px"} p={4} borderRadius={5}>
{response200 ? (
<JsonRow
depth={0}
isParentOpen={true}
defaultValues={response200 || undefined}
/>
) : (
<Text color="gray" textAlign={"center"}>
No Data
</Text>
)}
</Box>
</Box>
</Box>
);
};
const ParamTable = ({
defaultValue,
noPadding,
size,
...rest
}: {
defaultValue: RequestParam[];
noPadding?: boolean;
size?: string;
} & Omit<BoxProps, "defaultValue">) => {
return (
<TableContainer borderRadius={5} {...rest}>
<Table size={size} variant="simple">
<Thead>
<Tr>
<Th pl={noPadding ? 0 : undefined}>Name</Th>
<Th>Required</Th>
<Th>Type</Th>
<Th>Example</Th>
<Th pr={noPadding ? 0 : undefined}>Description</Th>
</Tr>
</Thead>
<Tbody>
{defaultValue.map((data, i) => (
<Tr key={i}>
<Td
pl={noPadding ? 0 : undefined}
borderBottomWidth={
i === defaultValue.length - 1 ? 0 : undefined
}
>
{data?.name}
</Td>
<Td
borderBottomWidth={
i === defaultValue.length - 1 ? 0 : undefined
}
>
{data?.isRequired ? "YES" : "NO"}
</Td>
<Td
borderBottomWidth={
i === defaultValue.length - 1 ? 0 : undefined
}
>
{data?.type.toLowerCase()}
</Td>
<Td
borderBottomWidth={
i === defaultValue.length - 1 ? 0 : undefined
}
>
{data?.example}
</Td>
<Td
pr={noPadding ? 0 : undefined}
borderBottomWidth={
i === defaultValue.length - 1 ? 0 : undefined
}
>
{data?.description}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
);
};
const JsonRow = ({
depth,
isParentOpen,
isArrayElem,
keyId,
defaultValues,
hidden,
...rest
}: {
depth: number;
isParentOpen?: boolean;
isArrayElem?: boolean;
keyId?: number;
defaultValues?: JsonNode;
} & BoxProps) => {
const { isOpen, onToggle } = useDisclosure({
defaultIsOpen: true,
});
const isRoot = depth === 0;
const headerColor = useColorModeValue("gray.600", "gray.400");
return (
<>
{isRoot && (
<Grid
templateColumns={"1fr 120px 1fr 1fr 1fr"}
py={3}
w="full"
{...rest}
borderBottomWidth={1}
fontSize={"xs"}
>
<Flex flex="0 0 320px">
<Text color={headerColor} fontWeight={"bold"}>
NAME
</Text>
</Flex>
<Center>
<Text color={headerColor} fontWeight={"bold"}>
REQUIRED
</Text>
</Center>
<Center>
<Text color={headerColor} fontWeight={"bold"}>
TYPE
</Text>
</Center>
<Center>
<Text color={headerColor} fontWeight={"bold"}>
MOCK
</Text>
</Center>
<Center>
<Text color={headerColor} fontWeight={"bold"}>
DESCRIPTION
</Text>
</Center>
</Grid>
)}
<Grid
templateColumns={"1fr 120px 1fr 1fr 1fr"}
py={3}
hidden={hidden}
w="full"
{...rest}
borderBottomWidth={1}
borderColor={"blackAlpha"}
>
<Flex pl={`${depth * 24}px`} flex="0 0 320px">
<Center cursor="pointer" onClick={onToggle}>
{defaultValues?.type === ParamType.OBJECT ||
defaultValues?.type === ParamType.ARRAY ? (
<Icon
fontSize={10}
mr={2}
as={isOpen ? BsFillCaretDownFill : BsFillCaretRightFill}
/>
) : undefined}
</Center>
<Text fontSize={"sm"}>
{isRoot ? "Root" : isArrayElem ? "Items" : defaultValues?.name}
</Text>
</Flex>
<Center>
<Text fontSize={"sm"}>
{defaultValues?.isRequired ? "YES" : "NO"}
</Text>
</Center>
<Center>
<Text fontSize={"sm"}>{defaultValues?.type.toLowerCase()}</Text>
</Center>
<Center>
<Text fontSize={"sm"}>
{isRoot || isArrayElem ? "Mock" : defaultValues?.mock}
</Text>
</Center>
<Center>
<Text fontSize={"sm"}>{defaultValues?.description}</Text>
</Center>
</Grid>
{defaultValues?.type === "ARRAY" && (
<JsonRow
hidden={hidden || !isParentOpen || !isOpen}
isParentOpen={isParentOpen && isOpen}
depth={depth + 1}
defaultValues={defaultValues.arrayElem}
/>
)}
{defaultValues?.type === "OBJECT" &&
defaultValues?.children?.map((child, i) => (
<JsonRow
key={i}
keyId={i}
hidden={hidden || !isParentOpen || !isOpen}
isParentOpen={isParentOpen && isOpen}
depth={depth + 1}
defaultValues={child}
/>
))}
</>
);
};
const BodyEditor = ({
type,
bodyJson,
bodyForm,
bodyRaw,
...rest
}: {
bodyForm: RequestParam[];
type: RequestBodyType;
bodyJson?: JsonNode;
bodyRaw?: RequestBodyRaw;
} & BoxProps) => {
return (
<Box {...rest}>
{type === "FORM" &&
(bodyForm.length > 0 ? (
<ParamTable defaultValue={bodyForm} />
) : (
<Box py={6}>
<Text color="gray" textAlign={"center"}>
No Data
</Text>
</Box>
))}
{type === "JSON" && (
<Box p={4}>
<JsonRow depth={0} isParentOpen={true} defaultValues={bodyJson} />
</Box>
)}
{type === "RAW" && (
<>
<Flex mt={3}>
<Text width={120}>Example:</Text>
{bodyRaw?.example}
</Flex>
<Flex mt={3}>
<Text width={120}>Description:</Text>
{bodyRaw?.description}
</Flex>
</>
)}
</Box>
);
};
export default Api;
================================================
FILE: app/routes/projects/$projectId/apis/..editor.tsx
================================================
import {
Box,
BoxProps,
Button,
Center,
Checkbox,
Container,
Divider,
Flex,
HStack,
Icon,
IconButton,
Input,
Menu,
MenuButton,
MenuItem,
MenuList,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
RadioProps,
Select,
Tab,
Table,
TableContainer,
TabList,
TabPanel,
TabPanels,
Tabs,
Tbody,
Td,
Text,
Textarea,
Th,
Thead,
Tooltip,
Tr,
useBoolean,
useColorModeValue,
useDisclosure,
useMultiStyleConfig,
useTab,
useToast,
VStack,
} from "@chakra-ui/react";
import {
ApiData,
ParamType,
Prisma,
RequestBodyType,
RequestMethod,
RequestParam,
} from "@prisma/client";
import { json, redirect } from "@remix-run/node";
import { useLoaderData, useTransition } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import React, {
PropsWithoutRef,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { BsFillCaretDownFill, BsFillCaretRightFill } from "react-icons/bs";
import { FiEye, FiMinus, FiPlus, FiSettings, FiTrash2 } from "react-icons/fi";
import {
useFormContext,
ValidatedForm,
validationError,
} from "remix-validated-form";
import invariant from "tiny-invariant";
import { string, z, ZodTypeDef } from "zod";
import { deleteApi, saveApiData } from "~/models/api.server";
import { JsonNode, JsonNodeType, RequestMethods } from "~/models/type";
import {
AceEditor,
FormCancelButton,
FormHInput,
FormInput,
FormSubmitButton,
Header,
PathInput,
} from "~/ui";
import { FormInputProps } from "~/ui/Form/FormHInput";
import ModalInput from "~/ui/Form/ModalInput";
import { PathInputProps } from "~/ui/Form/PathInput";
import { useIds, usePath } from "~/utils";
import { mockJson } from "~/utils/mock";
import { loader } from "./details.$apiId";
type JsonNodeFormElem = Omit<
Partial<JsonNode>,
"children" | "arrayElem" | "isRequired"
> & {
isRequired?: string;
};
type JsonNodeForm = JsonNodeFormElem & {
arrayElem?: JsonNodeForm;
children?: JsonNodeForm[];
};
type JsonNodeTransformedElem = Omit<JsonNodeFormElem, "isRequired"> & {
isRequired: boolean;
};
type JsonNodeTransformed = JsonNodeTransformedElem & {
arrayElem?: JsonNodeTransformed;
children?: JsonNodeTransformed[];
};
export const saveApiAction = async (apiId: string, formData: FormData) => {
let result = await validator.validate(formData);
if (result.error) {
return validationError(result.error);
}
let data = result.data;
let pathParams: RequestParam[] = formatZodParam(data.pathParams);
let queryParams: RequestParam[] = formatZodParam(data.queryParams);
let headers: RequestParam[] = formatZodParam(data.headers);
let bodyForm: RequestParam[] = formatZodParam(data.bodyForm);
let bodyJson = formatZodJson(data.bodyJson);
let responseJson = formatZodJson(data.response);
let apiData: ApiData = {
name: data.name,
path: data.path,
method: data.method,
description: data.description || null,
pathParams: pathParams,
queryParams: queryParams,
headers: headers,
bodyType: data.bodyType,
bodyForm: bodyForm,
bodyRaw: {
example: data.bodyRaw.example,
description: data.bodyRaw.description,
},
bodyJson: bodyJson as unknown as Prisma.JsonValue,
response: {
"200": responseJson as unknown as Prisma.JsonValue,
},
};
await saveApiData(apiId, apiData);
return json({});
};
const deleteValidator = withZod(z.object({}));
const JsonNodeZod: z.ZodType<
JsonNodeTransformedElem,
ZodTypeDef,
JsonNodeFormElem
> = z.lazy(() =>
z.object({
name: z.string().optional(),
mock: z.string().optional(),
example: z.string().optional(),
isRequired: z
.string()
.optional()
.transform((elem) => elem !== undefined),
description: z.string().optional(),
type: z.enum(JsonNodeType),
children: z.array(JsonNodeZod).optional(),
arrayElem: JsonNodeZod.optional(),
})
);
const BodyTypes = [
RequestBodyType.FORM,
RequestBodyType.JSON,
RequestBodyType.RAW,
] as const;
const zodParam = z
.object({
name: z.string().trim().optional(),
example: z.string().trim().optional(),
description: z.string().trim().optional(),
isRequired: z
.string()
.optional()
.transform((arg) => arg !== undefined),
type: z.nativeEnum(ParamType).optional(),
})
.array()
.optional();
const formatZodParam = (params: z.infer<typeof zodParam>) => {
return (params || [])
.filter((obj) => !!obj.name)
.map((obj) => {
const { name, example, description, ...rest } = obj;
invariant(name);
return {
name: name,
example: example || "",
description: description || "",
type: obj.type || ParamType.STRING,
...rest,
};
});
};
const formatZodJson = (json: JsonNodeTransformed): JsonNode => {
json.name = "root";
const formatZodJsonRec = (node: JsonNodeTransformed) => {
let { children, arrayElem, ...rest } = node;
let { name, description, type, isRequired, mock, example } = rest;
invariant(type);
if (!name) {
return undefined;
}
let newChildren: JsonNode[] =
children
?.map((elem) => formatZodJsonRec(elem))
.filter((elem): elem is JsonNode => !!elem) || [];
let newArrayElem = arrayElem ? formatZodJsonRec(arrayElem) : undefined;
let retval: JsonNode = {
name: name,
type: type,
description: description || "",
example: example || "",
mock: mock || "",
isRequired: !!isRequired,
children: newChildren,
arrayElem: newArrayElem,
};
return retval;
};
return formatZodJsonRec(json) as JsonNode;
};
const validator = withZod(
z.object({
name: z.string().trim(),
path: z.string().trim(),
method: z.enum(RequestMethods),
description: z.string().trim().optional(),
pathParams: zodParam,
queryParams: zodParam,
headers: zodParam,
bodyType: z.enum(BodyTypes),
bodyForm: zodParam,
bodyJson: JsonNodeZod,
bodyRaw: z.object({
example: z.string().trim(),
description: z.string().trim(),
}),
response: JsonNodeZod,
})
);
const RadioTab = React.forwardRef<HTMLInputElement, RadioProps>(
(props, ref) => {
const bgBW = useColorModeValue("white", "inherit");
const tabProps = useTab({ ...props, ref });
const isSelected = !!tabProps["aria-selected"];
const styles = useMultiStyleConfig("Tabs", tabProps);
const { children, ...rest } = tabProps;
const { name, value, defaultChecked } = props;
return (
<Box {...rest}>
<Flex as="label">
<Radio
bg={bgBW}
ref={ref}
isChecked={isSelected}
__css={styles.tab}
name={name}
value={value}
defaultChecked={defaultChecked}
/>
<Box ml={2}>{children}</Box>
</Flex>
</Box>
);
}
);
const jsonNodeToForm = (json: JsonNode) => {
const { type, children, arrayElem, isRequired, ...rest } = json;
const newChildren = children.map((elem) => jsonNodeToForm(elem));
const newArrayElem = arrayElem ? jsonNodeToForm(arrayElem) : undefined;
let retval: JsonNodeForm = {
type: type,
children: type === "ARRAY" ? [] : newChildren,
arrayElem: newArrayElem,
isRequired: isRequired ? "true" : undefined,
...rest,
};
return retval;
};
const FormPathInput = ({
labelWidth,
method,
bg,
onMethodChange,
defaultValue,
defaultParams,
}: Partial<FormInputProps> &
PathInputProps & {
defaultParams?: RequestParam[];
}) => {
let { path, setPath, params } = usePath(
typeof defaultValue === "string" ? defaultValue : ""
);
labelWidth ||= 0;
let prefix = "pathParams";
return (
<Box>
<FormHInput
isRequired
bg={bg}
labelWidth={labelWidth}
name="path"
label="Path"
as={PathInput}
autoComplete="off"
size="sm"
method={method}
onMethodChange={onMethodChange}
value={path}
onChange={(e) => setPath(e.target.value)}
/>
{params.length > 0 && (
<TableContainer mt={1} ml={labelWidth}>
<Table variant="unstyled" size={"sm"} colorScheme="teal">
<Tbody verticalAlign={"baseline"}>
{params.map((param, i) => {
let defaultValues = defaultParams?.find(
(elem) => elem.name === param
);
return (
<Tr key={param}>
<Td p={1} width="25%" pl={0}>
<HStack alignItems={"flex-start"}>
<Input
bg={bg}
size="sm"
value={param}
id={`${prefix}-${param}-name`}
name={`${prefix}[${i}].name`}
readOnly
cursor={"not-allowed"}
/>
<Tooltip label="Required">
<Center h={8}>
<Checkbox
id={`${prefix}-${param}-required`}
name={`${prefix}[${i}].isRequired`}
bg={bg}
isChecked
type="hidden"
readOnly
cursor={"not-allowed"}
/>
</Center>
</Tooltip>
</HStack>
</Td>
<Td p={1}>
<Select
bg={bg}
size="sm"
name={`${prefix}[${i}].type`}
defaultValue={defaultValues?.type || undefined}
>
{[ParamType.STRING, ParamType.INT, ParamType.FLOAT].map(
(type) => (
<option key={type} value={type}>
{type.toLowerCase()}
</option>
)
)}
</Select>
</Td>
<Td p={1}>
<FormInput
id={`${prefix}-${param}-example`}
bg={bg}
size="sm"
name={`${prefix}[${i}].example`}
placeholder="Example"
defaultValue={defaultValues?.example || undefined}
/>
</Td>
<Td p={1} pr={0}>
<HStack>
<FormInput
id={`${prefix}-${param}-description`}
bg={bg}
size="sm"
name={`${prefix}[${i}].description`}
as={ModalInput}
modal={{ title: "Description" }}
placeholder="Description"
defaultValue={defaultValues?.description || undefined}
/>
</HStack>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
)}
</Box>
);
};
const HeaderWithSubmission = ({
children,
...rest
}: { children: ReactNode } & BoxProps) => {
return (
<Flex justifyContent={"space-between"} alignItems="baseline" {...rest}>
<Header>{children}</Header>
<FormSubmitButton
name="_action"
value="saveApi"
type="submit"
colorScheme="blue"
size="sm"
>
Save
</FormSubmitButton>
</Flex>
);
};
const Editor = () => {
const bg = useColorModeValue("gray.100", "gray.700");
const bgBW = useColorModeValue("white", "gray.900");
const gray = useColorModeValue("gray.300", "gray.600");
const labelWidth = "100px";
const { api } = useLoaderData<typeof loader>();
let { response, bodyJson, ...rest } = api.data;
let response200 = response ? (response as any)["200"] : undefined;
const [method, setMethod] = useState(api.data.method);
const [bodyTabIndex, setBodyTabIndex] = useState(0);
const requestHasBody =
method === "POST" ||
method === "DELETE" ||
method === "PUT" ||
method === "PATCH";
useEffect(() => {
if (!requestHasBody) {
setBodyTabIndex(1);
}
}, [requestHasBody]);
let defaultValues = useMemo(() => {
return {
...rest,
response: response200
? jsonNodeToForm(response200 as unknown as JsonNode)
: undefined,
bodyJson: bodyJson
? jsonNodeToForm(bodyJson as unknown as JsonNode)
: undefined,
};
}, [api]);
const onMethodChange: React.ChangeEventHandler<HTMLSelectElement> =
useCallback(
function (e) {
setMethod(e.target.value as RequestMethod);
},
[method]
);
const transition = useTransition();
const toast = useToast();
useEffect(() => {
if (transition.state === "loading") {
let action = transition.submission?.formData?.get("_action");
if (action === "saveApi") {
toast({
title: "Your change has been saved.",
status: "success",
position: "top",
isClosable: true,
});
}
}
}, [transition.state]);
return (
<Box>
<Box
id="api-form"
key={`${api.id}-${api.updatedAt}`}
position={"relative"}
as={ValidatedForm}
method="patch"
validator={withZod(z.object({}))}
replace={true}
resetAfterSubmit
p={2}
>
<HeaderWithSubmission>General</HeaderWithSubmission>
<Box bg={bg} p={4}>
<Container maxW="container.lg">
<Box py={2}>
<FormHInput
isRequired
bg={bgBW}
labelWidth={labelWidth}
name="name"
label="Name"
size="sm"
as={Input}
autoComplete="off"
defaultValue={defaultValues.name}
/>
</Box>
<Box py={2}>
<FormPathInput
labelWidth={labelWidth}
bg={bgBW}
method={method}
onMethodChange={onMethodChange}
defaultValue={defaultValues.path}
defaultParams={defaultValues.pathParams}
/>
</Box>
<Box py={2}>
<FormHInput
bg={bgBW}
labelWidth={labelWidth}
name="description"
label="Description"
as={Textarea}
autoComplete="off"
size="sm"
rows={5}
defaultValue={defaultValues.description || ""}
/>
</Box>
</Container>
</Box>
<HeaderWithSubmission mt={10}>Request</HeaderWithSubmission>
<Box bg={bg} py={4}>
<Tabs
index={bodyTabIndex}
onChange={setBodyTabIndex}
variant="solid-rounded"
colorScheme="cyan"
>
<TabList display={"flex"} justifyContent="center">
<Tab hidden={!requestHasBody} flexBasis={"100px"}>
Body
</Tab>
<Tab flexBasis={"100px"}>Query</Tab>
<Tab flexBasis={"100px"}>Headers</Tab>
</TabList>
<Divider my={2} borderColor={gray} />
<TabPanels>
<TabPanel>
<BodyEditor
type={defaultValues.bodyType}
bodyJson={defaultValues.bodyJson}
bodyForm={defaultValues.bodyForm}
bodyRaw={defaultValues.bodyRaw}
/>
</TabPanel>
<TabPanel>
<ParamTable
defaultValue={defaultValues.queryParams}
prefix="queryParams"
/>
</TabPanel>
<TabPanel>
<ParamTable
defaultValue={defaultValues.headers}
prefix="headers"
/>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
<HeaderWithSubmission mt={10}>Response</HeaderWithSubmission>
<Box bg={bg} p={8}>
<JsonEditor
prefix="response"
isMock={true}
defaultValues={defaultValues.response}
/>
</Box>
</Box>
</Box>
);
};
const BodyEditor = React.memo(
({
type,
bodyJson,
bodyForm,
bodyRaw,
}: {
bodyForm: RequestParam[];
type: RequestBodyType;
bodyJson?: JsonNodeForm;
bodyRaw: {
example: string | null;
description: string | null;
} | null;
}) => {
const bgBW = useColorModeValue("white", "gray.900");
return (
<Tabs
defaultIndex={[
RequestBodyType.FORM,
RequestBodyType.JSON,
RequestBodyType.RAW,
].indexOf(type)}
>
<RadioGroup px={4} defaultValue={type}>
<TabList border={"none"} display={"flex"} gap={4}>
<RadioTab name="bodyType" value={RequestBodyType.FORM}>
form-data
</RadioTab>
<RadioTab name="bodyType" value={RequestBodyType.JSON}>
json
</RadioTab>
<RadioTab name="bodyType" value={RequestBodyType.RAW}>
raw
</RadioTab>
</TabList>
</RadioGroup>
<TabPanels mt={4}>
<TabPanel p={0}>
<ParamTable
defaultValue={bodyForm}
prefix="bodyForm"
types={[ParamType.STRING, ParamType.FILE]}
/>
</TabPanel>
<TabPanel>
<JsonEditor
defaultValues={bodyJson}
prefix="bodyJson"
isMock={false}
/>
</TabPanel>
<TabPanel>
<Box>
<FormInput
bg={bgBW}
as={Textarea}
name="bodyRaw.example"
label="Example"
container={{
mb: 6,
}}
defaultValue={bodyRaw?.example || undefined}
/>
<FormInput
bg={bgBW}
as={Textarea}
name="bodyRaw.description"
label="Description"
defaultValue={bodyRaw?.description || undefined}
/>
</Box>
</TabPanel>
</TabPanels>
</Tabs>
);
}
);
const ParamTable = React.memo(
({
prefix,
types,
defaultValue,
}: {
prefix: string;
types?: string[];
defaultValue: RequestParam[];
}) => {
const bgBW = useColorModeValue("white", "gray.900");
const { ids, pushId, removeId } = useIds(
Math.max(defaultValue.length, 1),
1
);
return (
<TableContainer>
<Table size={"sm"} colorScheme="teal">
<Thead>
<Tr>
<Th width={"20%"}>Name</Th>
{types && <Th>Type</Th>}
<Th width={"25%"}>Example</Th>
<Th>Description</Th>
</Tr>
</Thead>
<Tbody verticalAlign={"baseline"}>
{ids.map((id, i) => (
<Tr key={id}>
<Td>
<HStack alignItems={"flex-start"}>
<FormInput
id={`${prefix}-${id}-name`}
bg={bgBW}
size="sm"
name={`${prefix}[${i}].name`}
defaultValue={defaultValue[id]?.name}
/>
<Tooltip label="Required">
<Center h={8}>
<FormInput
as={Checkbox}
id={`${prefix}-${id}-required`}
bg={bgBW}
name={`${prefix}[${i}].isRequired`}
defaultChecked={defaultValue[id]?.isRequired}
/>
</Center>
</Tooltip>
</HStack>
</Td>
{types && (
<Td>
<Select
bg={bgBW}
size="sm"
name={`${prefix}[${i}].type`}
defaultValue={defaultValue[id]?.type}
>
{types.map((type) => (
<option key={type} value={type}>
{type.toLowerCase()}
</option>
))}
</Select>
</Td>
)}
<Td>
<FormInput
id={`${prefix}-${id}-example`}
bg={bgBW}
size="sm"
name={`${prefix}[${i}].example`}
/>
</Td>
<Td>
<HStack>
<FormInput
id={`${prefix}-${id}-description`}
bg={bgBW}
size="sm"
name={`${prefix}[${i}].description`}
as={ModalInput}
modal={{ title: "Description" }}
defaultValue={defaultValue[id]?.description || ""}
/>
<Button size="sm" onClick={() => removeId(id)}>
<Icon as={FiTrash2} />
</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
<Box textAlign={"center"} mt={4}>
<Button
size="sm"
colorScheme="blue"
variant={"outline"}
onClick={pushId}
>
<Icon as={FiPlus} /> Add
</Button>
</Box>
</TableContainer>
);
}
);
const JsonEditor = React.memo(
({
prefix,
isMock,
defaultValues,
}: {
prefix: string;
isMock: boolean;
defaultValues?: JsonNodeForm;
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<Box>
<VStack>
<JsonRow
depth={0}
isParentOpen={true}
prefix={prefix}
isMock={isMock}
defaultValues={defaultValues}
/>
</VStack>
<Center mt={8}>
<Button
onClick={onOpen}
colorScheme={"blue"}
variant="outline"
size="sm"
>
<Icon as={FiEye} />
<Text ml={2}>View Example</Text>
</Button>
<JsonExampleModal isOpen={isOpen} onClose={onClose} prefix={prefix} />
</Center>
</Box>
);
}
);
const JsonExampleModal = ({
isOpen,
onClose,
prefix,
}: {
isOpen: boolean;
onClose: () => void;
prefix: string;
}) => {
const form = useFormContext();
let [data, setData] = useState<string | undefined>(undefined);
const generateData = useCallback(async () => {
if (!isOpen) {
return;
}
let node: JsonNode | null = null;
if (prefix === "bodyJson") {
const result = await withZod(
z.object({
bodyJson: JsonNodeZod,
})
).validate(form.getValues());
if (result.data) {
node = formatZodJson(result.data.bodyJson);
}
} else if (prefix === "response") {
const result = await withZod(
z.object({
response: JsonNodeZod,
})
).validate(form.getValues());
if (result.data) {
node = formatZodJson(result.data.response);
}
}
let jsonString = "null";
if (node) {
let mock = mockJson(node);
jsonString = JSON.stringify(mock, null, 2);
}
setData(jsonString);
}, [isOpen]);
useEffect(() => {
generateData();
}, [isOpen]);
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Example</ModalHeader>
<ModalCloseButton />
<Divider />
<ModalBody p={0}>
<AceEditor
mode={"json"}
editorProps={{ $blockScrolling: true }}
showGutter={true}
showPrintMargin={false}
value={data}
tabSize={2}
height="500px"
width="100%"
readOnly
/>
</ModalBody>
<Divider />
<ModalFooter>
<Button
onClick={generateData}
m="auto"
size="sm"
variant="outline"
colorScheme={"blue"}
>
<Icon as={FiEye} mr={2} /> Generate
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
const JsonRow = React.memo(
({
depth,
isParentOpen,
isArrayElem,
keyId,
onAddSibling,
onDelete,
prefix,
isMock,
defaultValues,
hidden,
...rest
}: {
depth: number;
isParentOpen?: boolean;
isArrayElem?: boolean;
onAddSibling?: (id: number) => void;
onDelete?: (id: number) => void;
prefix: string;
keyId?: number;
isMock: boolean;
defaultValues?: JsonNodeForm;
} & BoxProps) => {
const types = [
ParamType.OBJECT,
ParamType.ARRAY,
ParamType.STRING,
ParamType.INT,
ParamType.FLOAT,
ParamType.BOOLEAN,
];
const bgBW = useColorModeValue("white", "gray.900");
const { isOpen, onToggle } = useDisclosure({
defaultIsOpen: true,
});
const isRoot = depth === 0;
const [type, setType] = useState<ParamType>(
defaultValues?.type || (isRoot ? ParamType.OBJECT : ParamType.STRING)
);
const [touched, setTouched] = useBoolean(
type === ParamType.OBJECT || type === ParamType.ARRAY
);
const { ids, pushId, removeId, insertAfterId } = useIds(
defaultValues?.children?.length || 1,
1
);
const blue = useColorModeValue("blue.500", "blue.200");
const value = isRoot ? "root" : isArrayElem ? "items" : undefined;
const readOnly = isRoot || isArrayElem;
return (
<>
<HStack hidden={hidden} w="full" {...rest} alignItems="flex-start">
<Center pl={`${depth * 24}px`} flex="0 0 320px">
<Center w={4} h={4} cursor="pointer" onClick={onToggle}>
{type === ParamType.OBJECT || type === ParamType.ARRAY ? (
<Icon
fontSize={10}
as={isOpen ? BsFillCaretDownFill : BsFillCaretRightFill}
/>
) : undefined}
</Center>
<FormInput
minW={16}
size="sm"
name={`${prefix}.name`}
placeholder="Name"
cursor={readOnly ? "not-allowed" : undefined}
bg={readOnly ? undefined : bgBW}
readOnly={readOnly}
value={value}
defaultValue={!!value ? undefined : defaultValues?.name || ""}
/>
</Center>
<Box>
<Tooltip label="Required">
<Center h={8}>
<FormInput
as={Checkbox}
name={`${prefix}.isRequired`}
bg={bgBW}
isDisabled={isArrayElem}
defaultChecked={
isArrayElem || !defaultValues || !!defaultValues?.isRequired
}
// value="true"
/>
</Center>
</Tooltip>
</Box>
<Select
onChange={(e) => {
setTouched.on();
setType(e.target.value as ParamType);
}}
bg={bgBW}
size="sm"
flex="0 0 100px"
defaultValue={type}
name={`${prefix}.type`}
>
{types.map((type) => (
<option key={type} value={type}>
{type.toLowerCase()}
</option>
))}
</Select>
{isMock ? (
<FormInput
bg={bgBW}
size="sm"
name={`${prefix}.mock`}
placeholder={"Mock"}
as={ModalInput}
modal={{ title: "Mock" }}
isDisabled={type === "ARRAY" || type === "OBJECT"}
defaultValue={defaultValues?.mock}
/>
) : (
<FormInput
bg={bgBW}
size="sm"
name={`${prefix}.example`}
placeholder={"Example"}
as={ModalInput}
modal={{ title: "Example" }}
isDisabled={type === "ARRAY" || type === "OBJECT"}
defaultValue={defaultValues?.example}
/>
)}
<FormInput
bg={bgBW}
size="sm"
name={`${prefix}.description`}
placeholder="Description"
as={ModalInput}
modal={{ title: "Description" }}
defaultValue={defaultValues?.description}
/>
{isArrayElem && type !== "OBJECT" ? (
<Box flexBasis={"64px"} flexShrink={0} flexGrow={0} />
) : (
<Flex flexBasis={"64px"} flexShrink={0} flexGrow={0}>
{isRoot ? (
<Button p={0} size="sm" colorScheme={"green"} variant="ghost">
<Icon as={FiSettings} />
</Button>
) : isArrayElem ? (
<Box w={8} h={8}></Box>
) : (
<Button
p={0}
size="sm"
colorScheme={"red"}
variant="ghost"
onClick={(e) => onDelete?.(keyId as number)}
>
<Icon as={FiMinus} />
</Button>
)}
{depth === 0 || isArrayElem || type !== ParamType.OBJECT ? (
<Button
p={0}
size="sm"
colorScheme={"blue"}
variant="ghost"
onClick={(e) => {
if (depth === 0 || isArrayElem) {
pushId();
} else {
onAddSibling?.(keyId as number);
}
}}
>
<Icon as={FiPlus} />
</Button>
) : (
<Menu size={"sm"} colorScheme={"blue"}>
<MenuButton
p={0}
as={IconButton}
icon={<FiPlus />}
colorScheme="blue"
size="sm"
variant={"ghost"}
/>
<MenuList zIndex={5}>
<MenuItem onClick={pushId}>Add child node</MenuItem>
<MenuItem onClick={(e) => onAddSibling?.(keyId as number)}>
Add sibling node
</MenuItem>
</MenuList>
</Menu>
)}
</Flex>
)}
</HStack>
{touched && (
<JsonRow
isParentOpen={isParentOpen && isOpen}
depth={depth + 1}
hidden={
hidden || !isParentOpen || !isOpen || type !== ParamType.ARRAY
}
isArrayElem
onAddSibling={insertAfterId}
onDelete={removeId}
prefix={`${prefix}.arrayElem`}
isMock={isMock}
defaultValues={defaultValues?.arrayElem}
/>
)}
{touched &&
ids.map((id, i) => (
<JsonRow
key={id}
keyId={id}
isParentOpen={isParentOpen && isOpen}
depth={depth + 1}
hidden={
hidden || !isParentOpen || !isOpen || type !== ParamType.OBJECT
}
onAddSibling={insertAfterId}
onDelete={removeId}
prefix={`${prefix}.children[${i}]`}
isMock={isMock}
defaultValues={defaultValues?.children?.[id]}
/>
))}
</>
);
}
);
export default Editor;
================================================
FILE: app/routes/projects/$projectId/apis/..postman.tsx
================================================
import { ApiData, RequestParam } from ".prisma/client";
import {
Box,
Button,
Center,
Checkbox,
Divider,
Flex,
FormControl,
FormLabel,
Grid,
GridItem,
Heading,
HStack,
Icon,
Input,
InputGroup,
InputLeftAddon,
InputRightAddon,
Select,
Spacer,
Tab,
Table,
TableContainer,
TabList,
TabPanel,
TabPanels,
Tabs,
Tag,
Tbody,
Td,
Text,
Textarea,
Th,
Thead,
Tooltip,
Tr,
useColorModeValue,
useToast,
VStack,
} from "@chakra-ui/react";
import { useLoaderData, useMatches, useParams } from "@remix-run/react";
import invariant from "tiny-invariant";
import { AceEditor, FormInput, ModalInput } from "~/ui";
import { loader } from "./details.$apiId";
import { SlRocket } from "react-icons/sl";
import { methodContainsBody, useIds, useUrl } from "~/utils";
import { FiAlertOctagon, FiPlus, FiRepeat, FiTrash2 } from "react-icons/fi";
import {
useFormContext,
ValidatedForm,
ValidatorData,
} from "remix-validated-form";
import { withZod } from "@remix-validated-form/with-zod";
import { any, z } from "zod";
import { ClientOnly, json } from "remix-utils";
import {
lazy,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import { mockJson } from "~/utils/mock";
import { JsonNode } from "~/models/type";
const zodParam = z
.object({
checked: z
.string()
.optional()
.transform((v) => v !== undefined),
name: z.string().optional(),
value: z.string().optional(),
})
.array()
.default([]);
const validator = withZod(
z.object({
path: zodParam,
query: zodParam,
query_ext: zodParam,
header: zodParam,
header_ext: zodParam,
bodyForm: zodParam,
bodyForm_ext: zodParam,
})
);
const Postman = () => {
const { projectId } = useParams();
invariant(projectId);
let tabWidth = "150px";
const bg = useColorModeValue("gray.50", "gray.800");
const { api } = useLoaderData<typeof loader>();
const url = useUrl();
const [bodyValue, setBodyValue] = useState(
api.data.bodyType === "RAW"
? api.data.bodyRaw?.example
: JSON.stringify(
mockJson(api.data.bodyJson as unknown as JsonNode, {
useExample: true,
}),
null,
2
)
);
const regenerateJson = useCallback(() => {
setBodyValue(
JSON.stringify(
mockJson(api.data.bodyJson as unknown as JsonNode),
null,
2
)
);
}, [api]);
const form = useFormContext("postman-form");
const [location, setLocation] = useState("");
const [response, setResponse] = useState<AxiosResponse | null>(null);
const [error, setError] = useState<any>(null);
const methodHasBody = methodContainsBody(api.data.method);
const toast = useToast();
useEffect(() => {
let lastExecLocation = localStorage.getItem("postman.lastExeclocation");
if (lastExecLocation) {
setLocation(lastExecLocation);
} else {
setLocation(`${url.origin}/mock/${projectId}`);
}
}, [url]);
const onSubmit: React.MouseEventHandler<HTMLButtonElement> =
useCallback(async () => {
let formData = form.getValues();
let result = await validator.validate(formData);
let data = result.data;
if (!data) {
// TODO
return;
}
localStorage.setItem("postman.lastExeclocation", location);
let paths = data.path;
for (let path of paths) {
if (!path.value || !path.value.trim()) {
toast({
title: "Could not send.",
description: `Path param {${path.name}} is required`,
status: "error",
duration: 5000,
isClosable: true,
position: "top",
});
}
}
let finalPath = api.data.path;
for (let path of paths) {
invariant(path.value);
finalPath = finalPath.replace(`{${path.name}}`, path.value);
}
let query = Array<typeof data.query[0]>()
.concat(data.query, data.query_ext)
.filter((param) => param.checked && !!param.name);
let header = Array<typeof data.header[0]>()
.concat(data.header, data.header_ext)
.filter((param) => param.checked && !!param.name);
let config: AxiosRequestConfig = {
method: api.data.method,
url: location + finalPath,
maxRedirects: 0,
validateStatus: () => true,
};
let queries: typeof config["params"] = {};
for (let elem of query) {
invariant(elem.name);
queries[elem.name] = elem.value;
}
let headers: typeof config["headers"] = {};
for (let elem of header) {
invariant(elem.name);
headers[elem.name] = elem.value;
}
config.headers = headers;
config.params = queries;
if (methodHasBody) {
if (api.data.bodyType === "FORM") {
let bodyForm = Array<typeof data.header[0]>()
.concat(data.bodyForm, data.bodyForm_ext)
.filter((param) => param.checked && !!param.name);
let formD = new FormData();
for (let elem of bodyForm) {
invariant(elem.name);
formD.append(elem.name, elem.value || "");
}
config.data = formD;
} else if (api.data.bodyType === "RAW") {
config.data = bodyValue;
} else if (api.data.bodyType === "JSON") {
try {
let data = JSON.parse(bodyValue ?? "");
config.data = data;
} catch (err) {
let errMsg = "";
if (typeof err === "string") {
errMsg = err;
} else if (err instanceof Error) {
errMsg = err.message;
}
toast({
title: "Could not send.",
description:
"An error happened when parsing request body to JSON: " +
errMsg,
status: "error",
duration: 5000,
isClosable: true,
position: "top",
});
return;
}
}
}
try {
let res = await axios(config);
setResponse(res);
setError(null);
} catch (err: any) {
setError(err);
}
}, [form, location, bodyValue, api]);
const responseDragRef = useRef<HTMLDivElement>();
const gridContainerRef = useRef<HTMLDivElement>();
const lastClientY = useRef(0);
const lastResponseHeight = useRef(0);
useEffect(() => {
lastResponseHeight.current = (window.innerHeight - 112) / 2;
const mousedown = (e: MouseEvent) => {
if (e.target !== responseDragRef.current) {
return;
}
lastClientY.current = e.clientY;
const handleMove = ({ clientY }: { clientY: number }) => {
const height =
lastResponseHeight.current - clientY + lastClientY.current;
lastClientY.current = clientY;
lastResponseHeight.current = height < 0 ? 0 : height;
if (gridContainerRef.current) {
gridContainerRef.current.style.gridTemplateRows = `112px 1fr ${lastResponseHeight.current}px `;
}
};
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", () => {
document.removeEventListener("mousemove", handleMove);
});
};
document.addEventListener("mousedown", mousedown);
return () => {
document.removeEventListener("mousedown", mousedown);
};
}, []);
return (
<Grid
h="full"
ref={gridContainerRef as RefObject<HTMLDivElement>}
templateRows={"112px calc(50% - 112px / 2) 1fr"}
as={Tabs}
colorScheme="blue"
fontSize={"sm"}
>
<Box bg={bg}>
<Flex p={4} as={InputGroup}>
<InputLeftAddon fontSize={"sm"}>{api.data.method}</InputLeftAddon>
<Input
fontSize={"sm"}
flex={"1"}
w="auto"
placeholder="http://example.com"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
<InputRightAddon fontSize={"sm"} flex="1" w="auto">
{api.data.path}
</InputRightAddon>
<Button ml="2" w="100px" colorScheme="blue" onClick={onSubmit}>
Send
</Button>
</Flex>
<Divider />
<TabList bg={bg} textAlign="center" justifyContent={"center"}>
{api.data.pathParams.length > 0 && (
<Tab fontSize={"sm"} w={tabWidth}>
Path
</Tab>
)}
{methodHasBody && (
<Tab fontSize={"sm"} w={tabWidth}>
Body
</Tab>
)}
<Tab fontSize={"sm"} w={tabWidth}>
Query
</Tab>
<Tab fontSize={"sm"} w={tabWidth}>
Headers
</Tab>
{/* <Tab fontSize={"sm"} w={tabWidth}>
Cookies
</Tab> */}
</TabList>
</Box>
<TabPanels
id="postman-form"
as={ValidatedForm}
validator={validator}
bg={bg}
overflowY={"auto"}
>
{api.data.pathParams.length > 0 && (
<TabPanel overflowY="auto">
<ParamTable prefix="path" data={api.data.pathParams} />
</TabPanel>
)}
{methodHasBody && (
<TabPanel h="full" p={0}>
{api.data.bodyType === "FORM" ? (
<Box p={4} h="full" overflowY="auto">
<ParamTable prefix="bodyForm" data={api.data.bodyForm} />
</Box>
) : (
<BodyEditor
value={bodyValue ?? undefined}
onChange={setBodyValue}
description={api.data.bodyRaw?.description || ""}
isJson={api.data.bodyType === "JSON"}
onRegenerate={regenerateJson}
/>
)}
</TabPanel>
)}
<TabPanel maxH="full" overflowY="auto">
<ParamTable prefix="query" data={api.data.queryParams} />
</TabPanel>
<TabPanel maxH="full" overflowY="auto">
<ParamTable prefix="header" data={api.data.headers} />
</TabPanel>
<TabPanel maxH="full" overflowY="auto">
<ParamTable prefix="cookies" data={[]} />
</TabPanel>
</TabPanels>
<Box bg={useColorModeValue("gray.100", "gray.700")} position={"relative"}>
<Box
ref={responseDragRef as RefObject<HTMLDivElement>}
position={"absolute"}
top={"-10px"}
left={0}
right={0}
role="group"
py={"10px"}
cursor={"ns-resize"}
zIndex={100}
>
<Box
_groupHover={{ opacity: 1 }}
opacity={0}
height={"1px"}
width={"100%"}
bgColor={"blue.500"}
/>
</Box>
<Box h={"full"} overflowY={"auto"}>
{error ? (
<ErrorEesponse err={error} />
) : response ? (
<Response response={response} />
) : (
<EmptyResponse />
)}
</Box>
</Box>
</Grid>
);
};
const ParamTable = ({
prefix,
data,
}: {
prefix: string;
data: RequestParam[];
}) => {
const bgBW = useColorModeValue("white", "gray.900");
const { ids, pushId, removeId } = useIds(data.length === 0 ? 1 : 0);
return (
<TableContainer>
<Table size={"sm"} colorScheme="teal" css={{ tableLayout: "fixed" }}>
<Thead>
<Tr>
<Th p={0} w={8}></Th>
<Th width={"25%"} pl={0}>
<Text ml={3}>Name</Text>
</Th>
<Th p={0}>
<Text ml={3}>Value</Text>
</Th>
<Th width="30%">Description</Th>
</Tr>
</Thead>
<Tbody verticalAlign={"baseline"}>
{data.map((param, i) => (
<Tr key={i}>
<Td p={0} pl={1}>
<Checkbox
mr={2}
isChecked={param.isRequired ? true : undefined}
isDisabled={param.isRequired}
defaultChecked
name={`${prefix}[${i}].checked`}
/>
{param.isRequired && (
<input type={"hidden"} name={`${prefix}[${i}].checked`} />
)}
</Td>
<Td pl={0}>
<FormControl isRequired={param.isRequired}>
<FormLabel fontSize={"sm"} m={0}>
<Text ml={3} as={"span"}>
{param.name}
</Text>
<input
type={"hidden"}
name={`${prefix}[${i}].name`}
value={param.name}
/>
</FormLabel>
</FormControl>
</Td>
<Td p={0}>
<Input
borderWidth={0}
bg={bgBW}
size="sm"
name={`${prefix}[${i}].value`}
defaultValue={param.example || undefined}
placeholder="Value"
/>
</Td>
<Td>
<Text textOverflow={"ellipsis"} overflow="hidden">
{param.description}
</Text>
</Td>
</Tr>
))}
{ids.map((id, i) => (
<Tr key={id}>
<Td p={0} pl={1}>
<Checkbox
mr={2}
defaultChecked
name={`${prefix}_ext[${i}].checked`}
/>
</Td>
<Td p={0} pr={4}>
<FormInput
borderWidth={0}
bg={bgBW}
size="sm"
name={`${prefix}_ext[${i}].name`}
placeholder="Name"
/>
</Td>
<Td p={0}>
<FormInput
borderWidth={0}
bg={bgBW}
size="sm"
name={`${prefix}_ext[${i}].value`}
placeholder="Value"
/>
</Td>
<Td p={0} pl={2}>
<Button
variant={"ghost"}
size="sm"
onClick={() => removeId(id)}
>
<Icon as={FiTrash2} />
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
<Box textAlign={"center"} mt={4}>
<Button
size="sm"
colorScheme="blue"
variant={"outline"}
onClick={pushId}
>
<Icon as={FiPlus} /> Add
</Button>
</Box>
</TableContainer>
);
};
const BodyEditor = ({
description,
onChange,
isJson,
value,
onRegenerate,
}: {
onChange?: (value: string, event?: any) => void;
value?: string;
description?: string;
isJson?: boolean;
onRegenerate?: () => void;
}) => {
const bgBW = useColorModeValue("white", "gray.900");
const [mode, setMode] = useState(isJson ? "json" : "plain_text");
return (
<Grid h="full" w="full" templateColumns={"3fr 1fr"}>
<Box h="full">
<ClientOnly>
{() => (
<AceEditor
mode={mode}
editorProps={{ $blockScrolling: true }}
height="100%"
width="100%"
showGutter={true}
showPrintMargin={false}
value={value}
onChange={onChange}
tabSize={2}
/>
)}
</ClientOnly>
</Box>
{isJson ? (
<Box h="full">
<HStack>
<Heading h={8} p={2} size="sm">
Json
</Heading>
<Spacer />
</HStack>
<Divider />
<Center h="calc(100% - 32px)">
<Button
size="sm"
variant={"outline"}
colorScheme="blue"
onClick={onRegenerate}
>
<Icon as={FiRepeat} mr={2} />
Generate
</Button>
</Center>
</Box>
) : (
<Box h="full">
<HStack>
<Heading p={2} size="sm">
RAW
</Heading>
<Spacer />
<Select
variant={"unstyled"}
width={20}
size="sm"
onChange={(e) => setMode(e.target.value)}
value={mode}
>
<option value="plain_text">Text</option>
<option value="json5">Json5</option>
<option value="xml">XML</option>
</Select>
</HStack>
<Divider />
<Text p={2}>{description}</Text>
</Box>
)}
</Grid>
);
};
const ErrorEesponse = ({ err }: { err: any }) => {
let msg =
(err instanceof AxiosError ? err.code : undefined) || "unkown error";
return (
<Center position={"relative"} h="full">
<Text color="gray.400" p={4} position={"absolute"} top={0} left={0}>
Response
</Text>
<VStack>
<Icon color="red.300" as={FiAlertOctagon} w={20} h={20} />
<br />
<Text>Could not send request</Text>
<Tag px={4} colorScheme={"red"}>
Error: {msg}
</Tag>
</VStack>
</Center>
);
};
const EmptyResponse = () => {
return (
<Center position={"relative"} h="full">
<Text color="gray.400" p={4} position={"absolute"} top={0} left={0}>
Response
</Text>
<VStack color="blue.300">
<Icon as={SlRocket} w={20} h={20} />
<br />
<Text>Click the "Send" button to get the return results</Text>
</VStack>
</Center>
);
};
const Response = ({ response }: { response: AxiosResponse }) => {
let gray = useColorModeValue("gray.700", "gray.200");
let contentType = (
typeof response.headers.getContentType === "function"
? response.headers.getContentType() || ""
: response.headers.getContentType || ""
).toString();
let mode = contentType.startsWith("text/html")
? "html"
: contentType.startsWith("application/json")
? "json"
: "plain_text";
let value = "";
if (response.data) {
if (typeof response.data === "object") {
value = JSON.stringify(response.data, null, 2);
} else {
value = response.data;
}
}
return (
<Tabs size={"sm"} h="full">
<TabList px={2} as={HStack} gap={0}>
<Tab fontSize={"sm"}>Body</Tab>
<Tab fontSize={"sm"}>Headers</Tab>
{/* TODO */}
{/* <Tab fontSize={"xs"}>Cookies</Tab> */}
<Spacer />
<Box px={2}>
<Text fontSize={"xs"} color={gray}>
Status: {response.status} {response.statusText}
</Text>
</Box>
</TabList>
<TabPanels h="calc(100% - 33px)" overflowY={"auto"}>
<TabPanel h="full" p={0}>
{value ? (
<AceEditor
mode={mode}
editorProps={{ $blockScrolling: true }}
height="100%"
width="100%"
showGutter={true}
showPrintMargin={false}
value={value}
tabSize={2}
readOnly
/>
) : (
<Center h="full">
<Text color={"gray.400"}>No Data</Text>
</Center>
)}
</TabPanel>
<TabPanel>
<TableContainer>
<Table size={"sm"} variant="simple" colorScheme={"teal"}>
<Thead>
<Tr>
<Th width={"4%"}></Th>
<Th width={"48%"}>Key</Th>
<Th width={"48%"}>Value</Th>
</Tr>
</Thead>
<Tbody>
{Object.entries(response.headers).map(([key, val], i) => (
<Tr key={i}>
<Td></Td>
<Td>{key}</Td>
<Td>{val}</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</TabPanel>
<TabPanel>
<p>three!</p>
</TabPanel>
</TabPanels>
</Tabs>
);
};
export default Postman;
================================================
FILE: app/routes/projects/$projectId/apis/details.$apiId.tsx
================================================
import { TabPanel, TabPanels } from "@chakra-ui/react";
import { ActionArgs, json, LoaderArgs } from "@remix-run/node";
import { RouteMatch, useMatches, useParams } from "@remix-run/react";
import invariant from "tiny-invariant";
import { getApiById, getApiProjectId } from "~/models/api.server";
import { httpResponse } from "~/utils";
import Editor, { saveApiAction } from "./..editor";
import Postman from "./..postman";
import Api from "./..api";
import { requireUserId } from "~/session.server";
import { checkAuthority } from "~/models/project.server";
import { ProjectUserRole } from "@prisma/client";
export const handle = {
tabs: (matches: RouteMatch[]) => {
const role = matches[1].data.role as ProjectUserRole;
const readOnly = role === "READ";
return readOnly ? ["Api", "Exec"] : ["Api", "Edit", "Exec"];
},
};
export const loader = async ({ request, params }: LoaderArgs) => {
let { apiId } = params;
invariant(apiId);
let userId = await requireUserId(request);
let api = await getApiById(apiId);
if (!api) {
throw httpResponse.BadRequest;
}
if (!(await checkAuthority(userId, api.projectId, ProjectUserRole.READ))) {
throw httpResponse.Forbidden;
}
return json({ api });
};
export const action = async ({ request, params }: ActionArgs) => {
let userId = await requireUserId(request);
let { apiId } = params;
invariant(apiId);
let projectId = await getApiProjectId(apiId);
if (!projectId) {
return httpResponse.BadRequest;
}
if (!(await checkAuthority(userId, projectId, "WRITE"))) {
return httpResponse.Forbidden;
}
let formData = await request.formData();
let action = formData.get("_action");
if (action === "saveApi") {
return await saveApiAction(apiId, formData);
}
throw httpResponse.NotFound;
};
export default function ApiInfo() {
const matches = useMatches();
const role = matches[1].data.role as ProjectUserRole;
const readOnly = role === "READ";
const { apiId } = useParams();
return (
<TabPanels h="full" overflowY={"hidden"} key={apiId}>
<TabPanel h="full" overflowY={"auto"}>
<Api />
</TabPanel>
{!readOnly && (
<TabPanel h="full" overflow={"auto"}>
<Editor />
</TabPanel>
)}
<TabPanel h="full" p={0}>
<Postman />
</TabPanel>
<TabPanel p={0}>Mock</TabPanel>
</TabPanels>
);
}
================================================
FILE: app/routes/projects/$projectId/apis/groups.$groupId.tsx
================================================
import {
Box,
Center,
Input,
TabPanel,
TabPanels,
Textarea,
useToast,
VStack,
} from "@chakra-ui/react";
import { ProjectUserRole } from "@prisma/client";
import { ActionArgs, json, LoaderArgs } from "@remix-run/node";
import { useLoaderData, useMatches, useTransition } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { useEffect, useRef } from "react";
import { ValidatedForm, validationError } from "remix-validated-form";
import invariant from "tiny-invariant";
import { z } from "zod";
import { getGroupById, updateGroup } from "~/models/api.server";
import { checkAuthority } from "~/models/project.server";
import { requireUserId } from "~/session.server";
import FormHInput from "~/ui/Form/FormHInput";
import FormSubmitButton from "~/ui/Form/FormSubmitButton";
import { httpResponse } from "~/utils";
export const handle = {
tabs: ["Edit Group"],
};
export const loader = async ({ request, params }: LoaderArgs) => {
let userId = await requireUserId(request);
let { groupId } = params;
if (!groupId) {
throw httpResponse.NotFound;
}
let group = await getGroupById(groupId);
invariant(group);
if (!(await checkAuthority(userId, group.projectId, "READ"))) {
throw httpResponse.Forbidden;
}
return json({ group: group });
};
export const action = async ({ request, params }: ActionArgs) => {
let { groupId } = params;
invariant(groupId);
let userId = await requireUserId(request);
let group = await getGroupById(groupId);
if (!group) {
return httpResponse.BadRequest;
}
if (!(await checkAuthority(userId, group.projectId, "WRITE"))) {
return httpResponse.Forbidden;
}
let formData = await request.formData();
let result = await validator.validate(formData);
if (result.error) {
throw validationError(result.error);
}
let updated = await updateGroup({
id: groupId,
name: result.data.name,
description: result.data.description,
});
return json(updated);
};
const validator = withZod(
z.object({
name: z.string().trim().min(1, "Group name is required"),
description: z.string().trim(),
})
);
export default function ApiGroup() {
const matches = useMatches();
const role = matches[1].data.role as ProjectUserRole;
const readOnly = role === "READ";
let { group } = useLoaderData<typeof loader>();
let defaultValue = {
name: group.name,
description: group.description,
};
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
formRef.current?.reset();
}, [group.id]);
const transition = useTransition();
const toast = useToast();
useEffect(() => {
if (transition.state === "loading") {
let action = transition.submission?.formData?.get("_action");
if (action === "saveGroup") {
toast({
title: "Your change has been saved.",
status: "success",
position: "top",
isClosable: true,
});
}
}
}, [transition.state]);
return (
<TabPanels overflowY={"auto"}>
<Box as={TabPanel} pb={5} pt={20} pr={20} mx="auto" maxW={"64rem"}>
<ValidatedForm
validator={validator}
method="patch"
defaultValues={defaultValue}
formRef={formRef}
>
<VStack spacing={6}>
<FormHInput
labelWidth="200px"
name="name"
label="Group name"
isRequired
as={Input}
readOnly={readOnly}
/>
<FormHInput
labelWidth="200px"
name="description"
label="Description"
as={Textarea}
readOnly={readOnly}
/>
<Center>
<FormSubmitButton
name="_action"
value="saveGroup"
colorScheme="blue"
px={12}
disabled={readOnly}
>
Save
</FormSubmitButton>
</Center>
</VStack>
</ValidatedForm>
</Box>
</TabPanels>
);
}
================================================
FILE: app/routes/projects/$projectId/apis/index.tsx
================================================
import {
Box,
Button,
Center,
Divider,
Flex,
Heading,
Icon,
Link,
Spacer,
Table,
TableContainer,
TabPanel,
TabPanels,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react";
import { RequestMethod } from "@prisma/client";
import { Link as RemixLink, useMatches, useParams } from "@remix-run/react";
import { FiPlus } from "react-icons/fi";
import invariant from "tiny-invariant";
import { Api, Group, Project } from "~/models/project.server";
import { Header } from "~/ui";
import { NewApiModal, useMethodTag } from "../apis";
export const handle = {
tabs: ["Overview"],
};
export default function ApiOverview() {
const matches = useMatches();
const { projectId } = useParams();
const { isOpen, onClose, onOpen } = useDisclosure();
const blue = useColorModeValue("blue.700", "blue.200");
let project = matches[1].data.project as Project;
invariant(project);
invariant(projectId);
let apis: Api[] = [];
let groupMap = new Map<string, Group>();
let stack = [project.root];
while (stack.length > 0) {
let group = stack.pop();
invariant(group);
groupMap.set(group.id, group);
stack = stack.concat(group.groups);
apis = apis.concat(group.apis);
}
if (apis.length === 0) {
return (
<Center>
<Box textAlign={"center"}>
<Heading>Create your first API definition to continue</Heading>
<Box pb={20} mt={40}>
<Button onClick={onOpen} colorScheme={"teal"} p={10} size="lg">
New API Definition
</Button>
</Box>
</Box>
<NewApiModal isOpen={isOpen} onClose={onClose} />
</Center>
);
}
return (
<TabPanels h="full" overflowY={"auto"} py={8} px={12} fontSize="sm">
<TabPanel>
<Flex>
<Header>{apis.length} API definition</Header>
<Spacer />
<Button size="sm" colorScheme={"blue"} onClick={onOpen}>
<Icon as={FiPlus} mr={1} /> New API
</Button>
<NewApiModal isOpen={isOpen} onClose={onClose} />
</Flex>
<Divider />
<TableContainer mt={4}>
<Table variant="striped">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Path</Th>
<Th isNumeric>Group</Th>
</Tr>
</Thead>
<Tbody>
{apis.map((api) => (
<Tr key={api.id}>
<Td>
<Link
color={blue}
as={RemixLink}
to={`/projects/${projectId}/apis/details/${api.id}`}
>
{api.data.name}
</Link>
</Td>
<Td>
<Flex alignItems={"center"}>
<MethodTag method={api.data.method} />
<Text ml={2}>{api.data.path}</Text>
</Flex>
</Td>
<Td isNumeric>{groupMap.get(api.groupId || "")?.name}</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</TabPanel>
</TabPanels>
);
}
const MethodTag = ({ method }: { method: RequestMethod }) => {
let { text, color } = useMethodTag(method);
return (
<Text fontWeight={700} fontSize="sm" color={color}>
{method.toUpperCase()}
</Text>
);
};
================================================
FILE: app/routes/projects/$projectId/apis.tsx
================================================
import Tree, {
ItemId,
moveItemOnTree,
mutateTree,
RenderItemParams,
TreeData,
TreeDestinationPosition,
TreeSourcePosition,
} from '@atlaskit/tree';
// @ts-ignore
import { resetServerContext } from 'react-beautiful-dnd-next';
import {
Box,
Button,
Center,
Divider,
Flex,
Grid,
GridItem,
Heading,
HStack,
Icon,
IconButton,
Input,
ListItem,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
Spacer,
Text,
Tooltip,
UnorderedList,
useColorMode,
useColorModeValue,
useDisclosure,
VStack,
} from '@chakra-ui/react';
import { ProjectUserRole, RequestMethod, RequestParam } from '@prisma/client';
import { ActionArgs, redirect } from '@remix-run/node';
import {
Link as RemixLink,
useCatch,
useFetcher,
useMatches,
} from '@remix-run/react';
import { withZod } from '@remix-validated-form/with-zod';
import { useEffect, useState } from 'react';
import {
BsFillCaretDownFill,
BsFillCaretRightFill,
BsFolder2Open,
} from 'react-icons/bs';
import {
FiFilePlus,
FiFolder,
FiFolderPlus,
FiTrash,
} from 'react-icons/fi';
import { Outlet, useParams } from 'react-router-dom';
import { ValidatedForm, validationError } from 'remix-validated-form';
import invariant from 'tiny-invariant';
import { z } from 'zod';
import {
createApi,
createGroup,
deleteApi,
deleteGroup,
updateApi,
updateGroup,
} from '~/models/api.server';
import { Api, checkAuthority, Group, Project } from '~/models/project.server';
import { RequestMethods } from '~/models/type';
import { requireUserId } from '~/session.server';
import { PathInput } from '~/ui';
import FormCancelButton from '~/ui/Form/FormCancelButton';
import FormHInput from '~/ui/Form/FormHInput';
import FormInput from '~/ui/Form/FormInput';
import FormModal from '~/ui/Form/FormModal';
import FormSubmitButton from '~/ui/Form/FormSubmitButton';
import { httpResponse, parsePath, useUrl } from '~/utils';
import TreeBuilder from '~/utils/treeBuilder';
import { ProjecChangeButton } from '../$projectId';
export const handle = {
sideNav: <SideNav />,
};
enum Action {
NEW_GROUP = 'NEW_GROUP',
NEW_API = 'NEW_API',
UPDATE_API = 'UPDATE_API',
UPDATE_GROUP = 'UPDATE_GROUP',
DELETE_API = 'DELETE_API',
DELETE_GROUP = 'DELETE_GROUP',
}
export const action = async ({ request, params }: ActionArgs) => {
let userId = await requireUserId(request);
let formData = await request.formData();
let { projectId } = params;
invariant(projectId);
if (!(await checkAuthority(userId, projectId, ProjectUserRole.WRITE))) {
return httpResponse.Forbidden;
}
switch (formData.get('_action')) {
case Action.NEW_GROUP:
return await newGroupAction(formData, projectId);
case Action.UPDATE_GROUP:
return await updateGroupAction(formData);
case Action.NEW_API:
return await newApiAction(formData, projectId);
case Action.UPDATE_API:
return await updateApiAction(formData);
case Action.DELETE_API:
return await deleteApiAction(formData);
case Action.DELETE_GROUP:
return await deleteGroupAction(formData);
default:
console.info('_action:', formData.get('_action'));
return httpResponse.NotFound;
}
};
const updateApiAction = async (formData: FormData) => {
const result = await withZod(
z.object({
id: z.string().min(1, 'id is required'),
groupId: z.string(),
data: z.any(),
})
).validate(formData);
if (result.error) {
return validationError(result.error);
}
return await updateApi(result.data.id, {
groupId: result.data.groupId,
data: result.data.data,
});
};
const newGroupAction = async (formData: FormData, projectId: string) => {
const result = await newGroupValidator.validate(formData);
if (result.error) {
return validationError(result.error);
}
const { parentId, name } = result.data;
let group = await createGroup({
parentId: parentId,
projectId: projectId,
name,
});
return redirect(`/projects/${group.projectId}/apis/groups/${group.id}`);
};
const updateGroupAction = async (formData: FormData) => {
const result = await withZod(
z.object({
id: z.string().min(1, 'id is required'),
parentId: z.string(),
name: z.string(),
description: z.string(),
})
).validate(formData);
if (result.error) {
return validationError(result.error);
}
const { id, name, description, parentId } = result.data;
return await updateGroup({ id, name, description, parentId });
};
const newApiAction = async (formData: FormData, projectId: string) => {
const result = await newApiValidator.validate(formData);
if (result.error) {
return validationError(result.error);
}
const { name, path, method, groupId } = result.data;
let { params } = parsePath(path);
let api = await createApi(projectId, groupId, {
name,
path,
method,
pathParams: params.map<RequestParam>((param) => ({
name: param,
example: '',
description: '',
isRequired: true,
type: 'STRING',
})),
});
return redirect(`/projects/${projectId}/apis/details/${api.id}`);
};
export const deleteApiAction = async (formData: FormData) => {
let id = formData.get('id')?.toString();
let apiId = formData.get('apiId')?.toString();
if (!id) {
return httpResponse.BadRequest;
}
let url = formData.get('url')?.toString() ?? '/';
let api = await deleteApi(id);
if (apiId === id) {
if (api?.groupId) {
return redirect(`/projects/${api.projectId}/apis/groups/${api.groupId}`);
} else if (api?.projectId) {
return redirect(`/projects/${api.projectId}/apis`);
} else {
return redirect(`/projects`);
}
}
return redirect(url);
};
export const deleteGroupAction = async (formData: FormData) => {
let id = formData.get('id')?.toString();
let apiId = formData.get('apiId')?.toString();
let groupId = formData.get('groupId')?.toString();
if (!id) {
return httpResponse.BadRequest;
}
let url = formData.get('url')?.toString() ?? '/';
let data = await deleteGroup(id);
if (!data) {
return httpResponse.BadRequest;
}
let { group, groupsToDelete, apisToDelete } = data;
if (
(groupId && groupsToDelete.indexOf(groupId) !== -1) ||
(apiId && apisToDelete.indexOf(apiId) !== -1)
) {
if (group.parentId) {
return redirect(
`/projects/${group.projectId}/apis/groups/${group.parentId}`
);
} else {
return redirect(`/projects/${group.projectId}/apis`);
}
}
return redirect(url);
};
function SideNav() {
const groupModal = useDisclosure();
const apiModal = useDisclosure();
return (
<Grid templateRows="50px 40px minmax(0, 1fr)" h="100vh" overflowX={'auto'}>
<ProjecChangeButton />
<GridItem>
<HStack px={2}>
<Heading ml="2" fontWeight={'500'} size={'sm'} color="gray.400">
APIs
</Heading>
<Spacer />
<Box>
{/* <Tooltip label="Clone">
<IconButton
aria-label="clone"
icon={<FiCopy />}
variant="ghost"
colorScheme="gray"
/>
</Tooltip> */}
<Tooltip label="New group">
<IconButton
aria-label="add group"
icon={<FiFolderPlus />}
variant="ghost"
colorScheme="gray"
onClick={(e) => {
if (e.target instanceof HTMLElement) {
e.target.blur();
}
groupModal.onOpen();
}}
/>
</Tooltip>
<NewGroupModal
isOpen={groupModal.isOpen}
onClose={groupModal.onClose}
/>
<Tooltip label="New Api">
<IconButton
aria-label="add api"
icon={<FiFilePlus />}
variant="ghost"
colorScheme="gray"
onClick={(e) => {
if (e.target instanceof HTMLElement) {
e.target.blur();
}
apiModal.onOpen();
}}
/>
</Tooltip>
<NewApiModal isOpen={apiModal.isOpen} onClose={apiModal.onClose} />
</Box>
</HStack>
<Divider />
</GridItem>
<GridItem overflowY={'auto'}>
<SideNavContent />
</GridItem>
</Grid>
);
}
const newGroupValidator = withZod(
z.object({
name: z.string().min(1, 'group name is required'),
parentId: z.string().optional(),
})
);
const NewGroupModal = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: (data?: any) => void;
}) => {
const params = useParams();
return (
<FormModal
isOpen={isOpen}
onClose={onClose}
validator={newGroupValidator}
replace
method="post"
size="lg"
action={`/projects/${params.projectId}/apis`}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>New Group</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<FormInput
name="name"
label="Name"
placeholder="Group name"
autoComplete="off"
/>
<input
name="parentId"
value={params.groupId || undefined}
type="hidden"
/>
</ModalBody>
<ModalFooter>
<FormCancelButton onClick={onClose} mr={3}>
Cancel
</FormCancelButton>
<FormSubmitButton
type="submit"
name="_action"
value={Action.NEW_GROUP}
colorScheme="blue"
>
Create
</FormSubmitButton>
</ModalFooter>
</ModalContent>
</FormModal>
);
};
const newApiValidator = withZod(
z.object({
name: z.string().trim().min(1, 'api name is required'),
path: z.string().trim().min(1, 'path is required'),
method: z.enum(RequestMethods),
groupId: z.string().trim().optional(),
})
);
export const NewApiModal = ({
isOpen,
onClose,
}: {
isOpen: ModalProps['isOpen'];
onClose: ModalProps['onClose'];
}) => {
const matches = useMatches();
const params = useParams();
const gray = useColorModeValue('gray.200', 'gray.700');
let groupId = '';
if (params.groupId) {
groupId = params.groupId;
} else if (params.apiId) {
groupId = matches?.[3].data?.api?.groupId;
}
let labelWidth = '60px';
return (
<FormModal
isOpen={isOpen}
onClose={onClose}
validator={newApiValidator}
replace
method="post"
size="xl"
action={`/projects/${params.projectId}/apis`}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>New Api</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<VStack spacing={5}>
<FormHInput
labelWidth={labelWidth}
name="name"
label="Name"
size="sm"
as={Input}
autoComplete="off"
/>
<FormHInput
labelWidth={labelWidth}
name="path"
label="Path"
as={PathInput}
autoComplete="off"
size="sm"
/>
<Flex width={'full'} pl={labelWidth} flexDir={'row'}>
<UnorderedList fontSize={'sm'}>
<ListItem>
The API path starts with{' '}
<Text borderRadius={4} px={1} as="span" bg={gray}>
/
</Text>
</ListItem>
<ListItem>
Use curly braces to indicate Path Params, such as
<Text borderRadius={4} px={1} as="span" bg={gray}>
{'/users/{id}'}
</Text>
</ListItem>
</UnorderedList>
</Flex>
</VStack>
<input type={'hidden'} name="groupId" value={groupId} />
</ModalBody>
<ModalFooter>
<FormCancelButton onClick={onClose} mr={3}>
Cancel
</FormCancelButton>
<FormSubmitButton
type="submit"
name="_action"
value={Action.NEW_API}
colorScheme="blue"
>
Create
</FormSubmitButton>
</ModalFooter>
</ModalContent>
</FormModal>
);
};
const SideNavContent = () => {
const matches = useMatches();
const params = useParams();
const project = matches[1].data.project as Project;
const [treeData, setTreeData] = useState<TreeData>(
new TreeBuilder('1', null)
);
const fetcher = useFetcher();
invariant(project);
useEffect(() => {
const complexTree = new TreeBuilder(1, null);
const generateTreeData = (group: Group, builder: TreeBuilder) => {
for (let g of group.groups) {
const childTree = new TreeBuilder(g.id, g);
generateTreeData(g, childTree);
builder.withSubTree(childTree);
}
for (let api of group.apis) {
builder.withLeaf(api.id, api);
}
};
generateTreeData(project.root, complexTree);
const buildData = complexTree.build();
Object.keys(buildData.items).some(itemId => {
const itemData = buildData.items[itemId].data;
if (itemData && (itemData.id === params.groupId || (itemData.data && itemData.id === params.apiId))) {
Object.keys(buildData.items).some(key => {
if(`${buildData.items[itemId].id}`.startsWith(key)){
buildData.items[key].isExpanded = true;
}
});
return true;
}
});
setTreeData(buildData);
}, [params.projectId, params.groupId, params.apiId]);
invariant(project);
const renderItem = ({
item,
onExpand,
onCollapse,
provided,
}: RenderItemParams) => {
return (
<Box
ref={provided.innerRef}
{...provided.draggableProps}
>
<Box {...provided.dragHandleProps}>
{item.data.data ? (
<File key={item.id} api={item.data} />
) : (
<Folder
key={item.id}
group={item.data}
isExpanded={item.isExpanded}
itemId={item.id}
onExpand={onExpand}
onCollapse={onCollapse}
onAdd={() => {}}
onDelete={() => {}}
/>
)}
</Box>
</Box>
);
};
const onExpand = (itemId: ItemId) => {
setTreeData(mutateTree(treeData, itemId, { isExpanded: true }));
};
const onCollapse = (itemId: ItemId) => {
setTreeData(mutateTree(treeData, itemId, { isExpanded: false }));
};
const onDragEnd = (
source: TreeSourcePosition,
destination?: TreeDestinationPosition
) => {
if (!destination) {
return;
}
const itemData =
treeData.items[treeData.items[source.parentId].children[source.index]].data;
const destItem = treeData.items[destination.parentId].data;
if (destItem.data) {
return;
}
itemData.data
? fetcher.submit(
{
id: itemData.id,
groupId: destItem.id,
_action: Action.UPDATE_API,
},
{
method: 'patch',
action: `/projects/${params.projectId}/apis`,
}
)
: fetcher.submit(
{
id: itemData.id,
name: itemData.name,
description: itemData.description,
parentId: destItem.id,
_action: Action.UPDATE_GROUP,
},
{
method: 'patch',
action: `/projects/${params.projectId}/apis`,
}
);
setTreeData(moveItemOnTree(treeData, source, destination));
};
resetServerContext();
return (
<Flex flexDir={'column'}>
<Tree
tree={treeData}
renderItem={renderItem}
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
isDragEnabled
isNestingEnabled
/>
</Flex>
);
};
const Folder = ({
itemId,
group,
onDelete,
onAdd,
onExpand,
onCollapse,
isExpanded,
}: {
itemId: ItemId;
group: Group;
onDelete: (value: string) => void;
isExpanded?: boolean;
onExpand: (itemId: ItemId) => void;
onCollapse: (itemId: ItemId) => void;
onAdd: (value: string) => void;
}) => {
const { projectId, groupId } = useParams<{
projectId: string;
groupId: string;
}>();
const isActive = groupId === group.id;
const bg = useColorModeValue('blue.200', 'blue.600');
const iconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.800');
const hoverColor = useColorModeValue('blue.100', 'blue.800');
const deleteModal = useDisclosure();
return (
<Flex border={'none'} flexDir="column">
<HStack
spacing={0}
w="full"
borderRadius={2}
px={2}
_hover={{ background: isActive ? undefined : hoverColor }}
cursor="pointer"
role="group"
h={8}
bg={isActive ? bg : undefined}
onClick={(_e) =>
isActive
? isExpanded
? onDelete(group.id)
: onAdd(group.id)
: undefined
}
>
<Center
mr={1}
w="4"
h="4"
borderRadius={'full'}
_groupHover={{ background: 'blackAlpha.50' }}
onClick={() => (isExpanded ? onCollapse(itemId) : onExpand(itemId))}
>
<Icon
as={isExpanded ? BsFillCaretDownFill : BsFillCaretRightFill}
color={iconColor}
fontSize={10}
/>
</Center>
<Box
as={RemixLink}
flexGrow={1}
display="flex"
alignItems={'center'}
to={`/projects/${projectId}/apis/groups/${group.id}`}
>
<Icon
as={isExpanded ? BsFolder2Open : FiFolder}
fontWeight="100"
color={iconColor}
mr={2}
/>
<Text py={1} userSelect={'none'}>
{group.name}
</Text>
</Box>
<Spacer />
<DeleteButton onOpen={deleteModal.onOpen} />
<DeleteApiDialog
isOpen={deleteModal.isOpen}
onClose={deleteModal.onClose}
name={group.name}
id={group.id}
isGroup={true}
/>
</HStack>
</Flex>
);
};
export const useMethodTag = (method: string) => {
let colorMode = useColorMode();
let color = '';
let [value, setValue] = useState(generator(method));
useEffect(() => {
setValue(generator(method));
}, [method, colorMode.colorMode]);
function generator(method: string) {
let text: string = method;
switch (method) {
case RequestMethod.GET:
color = 'green';
break;
case RequestMethod.POST:
color = 'orange';
break;
case RequestMethod.PUT:
color = 'blue';
break;
case RequestMethod.PATCH:
color = 'teal';
text = 'PAT';
break;
case RequestMethod.DELETE:
color = 'red';
text = 'DEL';
break;
case RequestMethod.HEAD:
color = 'purple';
break;
case RequestMethod.OPTIONS:
color = 'cyan';
}
color += colorMode.colorMode === 'light' ? '.600' : '.300';
return { text, color };
}
return value;
};
const MethodTag = ({ method }: { method: RequestMethod }) => {
let { text, color } = useMethodTag(method);
return (
<Text
fontWeight={700}
fontSize="xs"
mt={0.25}
color={color}
flexBasis="40px"
flexShrink={0}
flexGrow={0}
>
{text}
</Text>
);
};
export function CatchBoundary() {
const caught = useCatch();
return (
<Center pt={10}>
{caught.status}-{caught.statusText}
</Center>
);
}
const DeleteButton = ({ onOpen }: { onOpen: () => void }) => {
return (
<Button
display="none"
_groupHover={{
display: 'inline-flex',
}}
colorScheme={'teal'}
variant={'ghost'}
p={0}
size="xs"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpen();
}}
>
<Icon as={FiTrash} />
</Button>
);
};
const File = ({ api }: { api: Api }) => {
const { projectId, apiId } = useParams();
const bg = useColorModeValue('blue.200', 'blue.600');
const isActive = api.id === apiId;
invariant(projectId);
const hoverColor = useColorModeValue('blue.100', 'blue.800');
const deleteModal = useDisclosure();
return (
<Flex
as={RemixLink}
to={`/projects/${projectId}/apis/details/${api.id}`}
h="8"
px={4}
borderRadius={2}
_hover={{ background: isActive ? undefined : hoverColor }}
bg={isActive ? bg : undefined}
cursor="pointer"
role={'group'}
>
<HStack w="full" spacing={0} pr={2}>
<MethodTag method={api.data.method} />
<Text noOfLines={1}>{api.data.name}</Text>
<Spacer />
<DeleteButton onOpen={deleteModal.onOpen} />
<DeleteApiDialog
isOpen={deleteModal.isOpen}
onClose={deleteModal.onClose}
name={api.data.name}
id={api.id}
isGroup={false}
/>
</HStack>
</Flex>
);
};
const deleteValidator = withZod(z.object({}));
const DeleteApiDialog: React.FC<{
isOpen: boolean;
onClose: () => any;
name: string;
id: string;
isGroup: boolean;
}> = ({ isOpen, onClose, name, id, isGroup }) => {
const params = useParams();
const url = useUrl();
return (
<Modal size={'lg'} isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Please confirm</ModalHeader>
<ModalCloseButton />
<Divider />
<ModalBody py={8} textAlign={'center'}>
<Text>
Are you sure to delete '<strong>{name}</strong>'
{isGroup && <span> and it's contents</span>}?
</Text>
<Text>
This action <strong>cannot</strong> be undone
</Text>
</ModalBody>
<Divider />
<ModalFooter
as={ValidatedForm}
method="delete"
resetAfterSubmit
validator={deleteValidator}
action={`/projects/${params.projectId}/apis`}
>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="url" value={url.href} />
<input
type="hidden"
name={isGroup ? 'groupId' : 'apiId'}
value={id}
/>
<FormCancelButton onClick={onClose} mr={3}>
Cancel
</FormCancelButton>
<FormSubmitButton
colorScheme="red"
name="_action"
value={isGroup ? Action.DELETE_GROUP : Action.DELETE_API}
>
Delete
</FormSubmitButton>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default function Apis() {
return <Outlet />;
}
================================================
FILE: app/routes/projects/$projectId/settings/index.tsx
================================================
import {
Box,
Button,
Container,
Flex,
HStack,
Text,
Spacer,
useColorModeValue,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalCloseButton,
ModalHeader,
ModalBody,
Input,
Center,
Divider,
Alert,
AlertIcon,
} from "@chakra-ui/react";
import { ActionArgs, redirect } from "@remix-run/node";
import { Form, useMatches, useTransition } from "@remix-run/react";
import { withZod } from "@remix-validated-form/with-zod";
import { useEffect, useState } from "react";
import { json } from "remix-utils";
import { ValidatedForm, validationError } from "remix-validated-form";
import invariant from "tiny-invariant";
import { z } from "zod";
import {
Project,
updateProject,
findProjectMembersById,
transferProject,
checkAuthority,
} from "~/models/project.server";
import { FormInput, FormSubmitButton, Header } from "~/ui";
import { getUserById, getUserByName } from "~/models/user.server";
import { requireUserId } from "~/session.server";
import { httpResponse } from "~/utils";
import { ProjectUserRole } from "@prisma/client";
export const action = async ({ params, request }: ActionArgs) => {
let formData = await request.formData();
let { projectId } = params;
let userId = await requireUserId(request);
invariant(projectId);
if (!(await checkAuthority(userId, projectId, ProjectUserRole.ADMIN))) {
return httpResponse.Forbidden;
}
let action = formData.get("_action");
if (action === "renameProject") {
let result = await renameValidator.validate(formData);
if (result.error) {
return validationError(result.error);
}
await updateProject(projectId, { name: result.data.projectName });
} else if (action === "transferProject") {
let result = await transferValidator.validate(formData);
if (result.error) {
return validationError(result.error);
}
const transferUser = await getUserByName(result.data.userName);
if (!transferUser) {
return validationError(
{ fieldErrors: { userName: "User does not exist" } },
result.submittedData,
{ status: 404 }
);
}
let project = await findProjectMembersById(projectId);
if (!project) {
return httpResponse.BadRequest;
}
if (userId === transferUser.id) {
return validationError(
{ fieldErrors: { userName: "You can't choose yourself" } },
result.submittedData,
{ status: 403 }
);
}
let currentUser = await getUserById(userId);
if (!currentUser) {
return httpResponse.BadRequest;
}
await transferProject(project, transferUser, currentUser);
return redirect("/projects");
} else if (action === "deleteProject") {
await updateProject(projectId, { isDeleted: true });
return redirect("/projects");
}
return json({ success: true });
};
const renameValidator = withZod(
z.object({
projectName: z.string().trim().min(1, "Please input project name"),
})
);
const deleteValidator = withZod(z.object({}));
const transferValidator = withZod(
z.object({
userName: z.string().trim().min(1, "Please input user name"),
})
);
const TransferDialog: React.FC<{
isOpen: boolean;
onClose: () => any;
project: Project;
}> = ({ isOpen, onClose, project }) => {
const [isDisabled, setIsDisabled] = useState(true);
const bgBW = useColorModeValue("white", "gray.900");
return (
<Modal size={"lg"} isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Transfer Project</ModalHeader>
<ModalCloseButton />
<ModalBody
pb={6}
as={ValidatedForm}
replace
method="patch"
resetAfterSubmit
validator={transferValidator}
>
<Alert status="warning">
<AlertIcon />
This action{" "}
<Text display={"inline-block"} fontWeight={"bold"} mx={1}>
cannot
</Text>{" "}
be undone.
</Alert>
<Text mt={5}>{"New owner's user name"}</Text>
<FormInput label=" " name="userName" bg={bgBW} />
<Text mt={5}>
Type <Text as="strong">{project.name}</Text> to confirm.
</Text>
<Input
mt={1}
bg={bgBW}
onChange={(e) => setIsDisabled(e.target.value !== project.name)}
/>
<Divider orientation="horizontal" />
<Center mt={10}>
<FormSubmitButton
colorScheme="red"
isDisabled={isDisabled}
name="_action"
value="transferProject"
ml={3}
>
I understand, transfer this project.
</FormSubmitButton>
</Center>
</ModalBody>
</ModalContent>
</Modal>
);
};
const DeleteDialog: React.FC<{
isOpen: boolean;
onClose: () => any;
project: Project;
}> = ({ isOpen, onClose, project }) => {
const [isDisabled, setIsDisabled] = useState(true);
const bgBW = useColorModeValue("white", "gray.900");
return (
<Modal size={"lg"} isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader fontSize="lg" fontWeight="bold">
Are you absolutely sure?
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={10}>
<Alert status="warning">
<AlertIcon />
Unexpected bad things will happen if you don't read this!
</Alert>
<Box mt={5}>
This action{" "}
<Text display={"inline-block"} fontWeight={"bold"} mx={1}>
cannot
</Text>{" "}
be undone. This will permanently delete the
<Text display={"inline-block"} fontWeight={"bold"} mx={1}>
{project.name}
</Text>{" "}
project and remove all data.
</Box>
<Flex mt={5}>
Please type{" "}
<Text fontWeight={"bold"} mx={2}>
{project.name}
</Text>{" "}
to confirm.
</Flex>
<Input
mt={2}
bg={bgBW}
onChange={(e) => setIsDisabled(e.target.value !== project.name)}
/>
<Center
mt={10}
as={ValidatedForm}
replace
method="patch"
resetAfterSubmit
validator={deleteValidator}
>
<FormSubmitButton
colorScheme="red"
isDisabled={isDisabled}
name="_action"
value="deleteProject"
ml={3}
>
I understand the consequences, delete this project
</FormSubmitButton>
</Center>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default function () {
const matches = useMatches();
const project = matches[1].data.project as Project;
invariant(project);
const role = matches[1].data.role as ProjectUserRole;
const isAdmin = role === "ADMIN";
const bg = useColorModeValue("gray.100", "gray.700");
const bgBW = useColorModeValue("white", "gray.900");
const [deleteVisible, setDeleteVisible] = useState(false);
const [transferVisible, setTransferVisible] = useState(false);
const transition = useTransition();
const toast = useToast();
useEffect(() => {
if (transition.state === "loading") {
let action = transition.submission?.formData?.get("_action");
if (action === "renameProject") {
toast({
title: "Project successfully renamed.",
status: "success",
position: "top",
isClosable: true,
});
} else if (action === "changePassword") {
toast({
title: "Password changed.",
status: "success",
position: "top",
isClosable: true,
});
} else if (action === "deleteProject") {
transition.type === "actionRedirect" &&
toast({
title: "Project successfully deleted",
status: "success",
position: "top",
isClosable: true,
});
} else if (action === "transferProject") {
transition.type === "actionRedirect" &&
toast({
title: "Project successfully transferred",
status: "success",
position: "top",
isClosable: true,
});
}
}
}, [transition.state]);
return (
<Box h="100%" overflowY={"auto"}>
<Container maxW={"container.md"} p={8} pb={10}>
<Header>General</Header>
<Box
bg={bg}
as={ValidatedForm}
validator={renameValidator}
replace
method="patch"
resetAfterSubmit
p={8}
>
<HStack alignItems={"start"}>
<FormInput
isRequired
label="Project name"
name="projectName"
bg={bgBW}
defaultValue={project.name}
readOnly={!isAdmin}
/>
<Spacer />
<Box>
<FormSubmitButton
mt={8}
name="_action"
value="renameProject"
colorScheme={"blue"}
disabled={!isAdmin}
>
Rename
</FormSubmitButton>
</Box>
</HStack>
</Box>
<Header mt={12}>Danger Zone</Header>
<Box bg={bg} p={8} w="full">
<HStack as={Form} replace method="patch">
<Flex flexDir={"column"}>
<Text fontWeight={"medium"}>Transfer Ownership</Text>
<Text w="full">Transfer this project to another user</Text>
</Flex>
<Spacer />
<Button
px={6}
variant={"outline"}
colorScheme={"red"}
onClick={() => setTransferVisible(true)}
disabled={!isAdmin}
>
Transfer
<TransferDialog
isOpen={transferVisible}
onClose={() => setTransferVisible(false)}
project={project}
/>
</Button>
</HStack>
<HStack mt={6} as={Form} replace method="patch">
<Flex flexDir={"column"}>
<Text fontWeight={"medium"}>Delete this project</Text>
<Text w="full">
Once you delete a project, there is no going back. Please be
certain.
</Text>
</Flex>
<Spacer />
<Button
px={6}
variant={"outline"}
colorScheme={"red"}
onClick={() => setDeleteVisible(true)}
disabled={!isAdmin}
>
Delete
<DeleteDialog
isOpen={deleteVisible}
onClose={() => setDeleteVisible(false)}
project={project}
/>
</Button>
</HStack>
</Box>
</Container>
</Box>
);
}
================================================
FILE: app/routes/projects/$projectId/settings/members.tsx
================================================
import {
Box,
Button,
Divider,
Flex,
Icon,
Spacer,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
Text,
useDisclosure,
Select,
ModalOverlay,
ModalContent,
ModalCloseButton,
ModalBody,
ModalFooter,
Center,
RadioGroup,
VStack,
Radio,
useToast,
Spinner,
AlertDialog,
AlertDialogOverlay, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogBody,
} from '@chakra-ui/react';
import { ProjectUser, ProjectUserRole, User } from '@prisma/client';
import { ActionArgs, json, LoaderArgs } from '@remix-run/node';
import {
useCatch,
useFetcher,
useLoaderData,
useMatches,
} from '@remix-run/react';
import { withZod } from '@remix-validated-form/with-zod';
import React, { RefObject, useEffect, useRef, useState } from 'react';
import { FiBook, FiChevronDown, FiPlus, FiTrash } from 'react-icons/fi';
import { validationError, } from 'remix-validated-form';
import invariant from 'tiny-invariant';
import { z } from 'zod';
import {
addMemberToProject, changeProjectMembers,
changeProjectRole,
findProjectMembersById,
getProjectById,
Project,
} from '~/models/project.server';
import { ProjectUserRoles } from '~/models/type';
import { getUserByEmail, getUserInfoByIds } from '~/models/user.server';
import { requireUserId } from '~/session.server';
import { FormInput, FormModal, FormSubmitButton, Header } from '~/ui';
import { httpResponse } from '~/utils';
import { FocusableElement } from '@chakra-ui/utils';
export const loader = async ({ params }: LoaderArgs) => {
let { projectId } = params;
invariant(projectId);
let project = await getProjectById(projectId);
if (!project) {
throw httpResponse.BadRequest;
}
let members = await getUserInfoByIds(project.members.map((item) => item.id));
let roleMap = project.members.reduce((prev, curr) => {
prev[curr.id] = curr.role;
return prev;
}, {} as { [key: string]: ProjectUserRole });
let retval = members.map(
(member) =>
({
...member,
role: roleMap[member.id],
} as typeof member & { role: ProjectUserRole })
);
return json({ members: retval });
};
export const action = async ({ request, params }: ActionArgs) => {
let userId = await requireUserId(request);
let formData = await request.formData();
let { projectId } = params;
invariant(projectId);
let project = await findProjectMembersById(projectId);
if (!project) {
return httpResponse.BadRequest;
}
if (
project.members.find((member) => member.id === userId)?.role !== 'ADMIN'
) {
return httpResponse.Forbidden;
}
let action = formData.get('_action');
switch (action) {
case 'addMember':
return addMemberAction(project, formData);
case 'changeRole':
return changeRoleAction(project, formData);
case 'deleteMember':
return deleteMemberAction(project, formData);
default:
return httpResponse.BadRequest;
}
};
const addMemberAction = async (
project: {
id: string;
members: ProjectUser[];
name: string;
},
formData: FormData
) => {
let result = await addMemberValidator.validate(formData);
if (result.error) {
return validationError(result.error);
}
let user = await getUserByEmail(result.data.email);
if (!user) {
return validationError({
formId: result.formId,
fieldErrors: {
email: `${result.data.email} is not a registered user.`,
},
});
}
for (let member of project.members) {
if (member.id === user.id) {
return validationError({
formId: result.formId,
fieldErrors: {
email: `${result.data.email} is already a member of ${project.name}.`,
},
});
}
}
await addMemberToProject(project.id, user.id, result.data.role);
return httpResponse.OK;
};
const changeRoleAction = async (
project: {
id: string;
members: ProjectUser[];
},
formData: FormData
) => {
let result = await withZod(
z.object({
id: z.string(),
role: z.enum(ProjectUserRoles),
_action: z.string(),
})
).validate(formData);
if (result.error) {
return validationError(result.error);
}
let data = result.data;
let numOfAdmins = project.members.filter((member) =>
member.id === data.id ? data.role === 'ADMIN' : member.role === 'ADMIN'
).length;
if (numOfAdmins === 0) {
return validationError(
{
formId: result.formId,
fieldErrors: {
role: `Project should have at least 1 admin`,
},
},
data
);
}
await changeProjectRole(project.id, data.id, data.role);
return json(result.data);
};
const deleteMemberAction = async (
project: {
id: string;
members: ProjectUser[];
},
formData: FormData
) => {
let result = await withZod(
z.object({
id: z.string(),
_action: z.string(),
})
).validate(formData);
if (result.error) {
return validationError(result.error);
}
let data = result.data;
let numOfAdmins = project.members.filter((member) =>
member.id !== data.id && member.role === 'ADMIN'
).length;
if (numOfAdmins === 0) {
return validationError(
{
formId: result.formId,
fieldErrors: {
role: `Project should have at least 1 admin`,
},
},
data
);
}
await changeProjectMembers(project.id, data.id);
return json(result.data);
};
export function CatchBoundary() {
const caught = useCatch();
return (
<>
<div>
ERROR: {caught.statusText} {caught.status}
</div>
<div>{caught.data.message}</div>
</>
);
}
export default function () {
const { isOpen, onOpen, onClose } = useDisclosure();
const matches = useMatches();
const project = matches[1].data.project as Project;
const role = matches[1].data.role as ProjectUserRole;
let { members } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const toast = useToast();
const isAdmin = role === 'ADMIN';
const [deleteDialogVisible, setDeleteDialogVisible] = useState(false);
const deleteMember = useRef<{id: string; name: string}>();
const onRoleChange = (id: string, role: ProjectUserRole) => {
fetcher.submit(
{
id: id,
role: role,
_action: 'changeRole',
},
{
method: 'patch',
replace: true,
}
);
};
const onDelete = () => {
fetcher.submit(
{
id: deleteMember.current!.id,
_action: 'deleteMember',
},
{
method: 'patch',
replace: true,
}
);
};
const isLoading = fetcher.state !== 'idle';
useEffect(() => {
if (fetcher.type === 'done') {
let errMsg = fetcher.data?.fieldErrors?.role;
if (errMsg) {
toast({
title: 'Could not change role.',
description: errMsg,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top',
});
} else {
const { id, role, _action } = fetcher.data;
if (_action === 'changeRole') {
const member = members.find((elem) => elem.id === id);
toast({
title: 'Action Succeed',
description: `User ${member?.name} changed to ${role}`,
status: 'success',
duration: 5000,
isClosable: true,
position: 'top',
});
}else if (_action === 'deleteMember') {
setDeleteDialogVisible(false);
toast({
title: 'Action Succeed',
description: `User ${deleteMember.current?.name} is deleted`,
status: 'success',
duration: 5000,
isClosable: true,
position: 'top',
});
}
}
}
}, [fetcher.type]);
return (
<Box h="100%" overflowY={'auto'} px={12} py={9} fontSize="sm">
<Flex>
<Header>{1} Members</Header>
<Spacer />
<Button
disabled={!isAdmin}
size="sm"
colorScheme={'blue'}
onClick={onOpen}
>
<Icon as={FiPlus} mr={1} /> Member
</Button>
<AddMemberModal isOpen={isOpen} onClose={onClose} project={project} />
</Flex>
<Divider />
<TableContainer mt={'10px'}>
<Table size={'sm'}>
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th w={40} isNumeric>
Role
</Th>
<Th px={0} w={10}></Th>
</Tr>
</Thead>
<Tbody>
{members.map((member) => (
<Tr key={member.id}>
<Td>{member.name}</Td>
<Td>
<Text>{member.email}</Text>
</Td>
<Td isNumeric>
<Select
value={member.role}
onChange={(e) =>
onRoleChange(member.id, e.target.value as ProjectUserRole)
}
size="sm"
icon={
isLoading ? <Spinner size={'sm'} /> : <FiChevronDown />
}
disabled={!isAdmin || isLoading}
>
<option value={ProjectUserRole.ADMIN}>Admin</option>
<option value={ProjectUserRole.WRITE}>Write</option>
<option value={ProjectUserRole.READ}>Read</option>
</Select>
</Td>
<Td px={0}>
<Button
disabled={!isAdmin}
colorScheme={'red'}
size="sm"
variant={'ghost'}
onClick={() => {
setDeleteDialogVisible(true);
deleteMember.current = member;
}}
>
<Icon as={FiTrash} />
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<DeleteDialog isLoading={isLoading} isOpen={deleteDialogVisible} onClose={() => setDeleteDialogVisible(false)} onDelete={onDelete} />
</Box>
);
}
const DeleteDialog: React.FC<{
isOpen: boolean;
onClose: () => any;
onDelete: () => any;
isLoading: boolean;
}> = ({ isOpen, onClose, onDelete, isLoading }) => {
const cancelRef = useRef<FocusableElement>();
return <AlertDialog
isOpen={isOpen}
onClose={onClose}
leastDestructiveRef={cancelRef as RefObject<FocusableElement>}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete Confirm
</AlertDialogHeader>
<AlertDialogBody>
Are you sure? You can't undo this action afterwards.
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef as RefObject<HTMLButtonElement>} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="red" isLoading={isLoading} ml={3} onClick={onDelete}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>;
};
const addMemberValidator = withZod(
z.object({
email: z.string().trim().min(1, 'Please input the email address of the member to invite.').email('Invalid email format'),
role: z.enum(ProjectUserRoles),
})
);
const AddMemberModal = ({
isOpen,
onClose,
project,
}: {
isOpen: boolean;
onClose: () => void;
project: Project;
}) => {
return (
<FormModal
isOpen={isOpen}
onClose={onClose}
validator={addMemberValidator}
replace
method="post"
size="2xl"
>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<Center pt={20} pb={12} flexDir={'column'}>
<Icon as={FiBook} display="block" w={12} h={12} color="gray.500" />
<Text fontSize="lg" mt={4}>
Add people to{' '}
<Text as="b" fontWeight={'bold'}>
{project.name}
</Text>
</Text>
</Center>
<ModalBody px={0} pb={6}>
<Box px={8}>
<FormInput name="email" placeholder="Email" />
</Box>
<Text fontSize={'sm'} px={8} mt={8} color="gray.500">
Choose a role
</Text>
<Divider />
<RadioGroup size={'sm'} defaultValue={ProjectUserRole.READ}>
<VStack alignItems={'baseline'}>
<Radio px={8} py={2} name="role" value={ProjectUserRole.READ}>
<Flex flexDir={'column'}>
<Text fontWeight={'bold'}>Read</Text>
<Text>
Recommended for non-code contributors who want to view or
discuss your project.
</Text>
</Flex>
</Radio>
<Divider style={{ marginTop: 0 }} />
<Radio px={8} py={2} name="role" value={ProjectUserRole.WRITE}>
<Flex flexDir={'column'}>
<Text fontWeight={'bold'}>Write</Text>
<Text>
Recommended for contributors who actively edit to your
project.
</Text>
</Flex>
</Radio>
<Divider style={{ marginTop: 0 }} />
<Radio px={8} py={2} name="role" value={ProjectUserRole.ADMIN}>
<Flex flexDir={'column'}>
<Text fontWeight={'bold'}>Admin</Text>
<Text>
Recommended for people who need full access to the project,
including sensitive and destructive actions like managing
members or deleting a project.
</Text>
</Flex>
</Radio>
<Divider style={{ marginTop: 0 }} />
</VStack>
</RadioGroup>
</ModalBody>
<ModalFooter mb={6} justifyContent={'center'}>
<FormSubmitButton
px={12}
type="submit"
colorScheme="blue"
name="_action"
value="addMember"
>
Add
</FormSubmitButton>
</ModalFooter>
</ModalContent>
</FormModal>
);
};
================================================
FILE: app/routes/projects/$projectId/settings.tsx
================================================
import {
Box,
Divider,
Flex,
FlexProps,
Grid,
Icon,
Link,
Spacer,
Text,
useColorModeValue,
VStack,
} from "@chakra-ui/react";
import { NavLink, Outlet, useParams } from "@remix-run/react";
import { ReactNode } from "react";
import { IconType } from "react-icons";
import { FiSettings, FiUsers } from "react-icons/fi";
import invariant from "tiny-invariant";
import { ProjecChangeButton } from "../$projectId";
export const handle = {
sideNav: <SideNav />,
tabs: ["Settings"],
};
function SideNav() {
const { projectId } = useParams();
invariant(projectId);
return (
<Grid
position={"relative"}
bg={useColorModeValue("gray.50", "gray.800")}
templateRows="50px 1px minmax(0, 1fr)"
h="100vh"
>
<ProjecChangeButton />
<Divider />
<VStack spacing={0}>
<NavItem
to={`/projects/${projectId}/settings`}
w="full"
icon={FiSettings}
end
>
General
</NavItem>
<NavItem
to={`/projects/${projectId}/settings/members`}
w="full"
icon={FiUsers}
>
Members
</NavItem>
</VStack>
</Grid>
);
}
interface NavItemProps extends FlexProps {
icon: IconType;
children: ReactNode;
isActive?: boolean;
to: string;
end?: boolean;
}
const NavItem = ({ icon, children, to, end, ...rest }: NavItemProps) => {
const hoverBG = useColorModeValue("cyan.100", "cyan.800");
const activeBG = useColorModeValue("blue.200", "blue.700");
return (
<NavLink to={to} style={{ width: "100%" }} end={end}>
{({ isActive }) => (
<Flex
w="full"
align="center"
py={2}
px={4}
// borderRadius="lg"
cursor="pointer"
_hover={{
bg: isActive ? activeBG : hoverBG,
}}
bg={isActive ? activeBG : undefined}
{...rest}
>
{icon && <Icon mr="4" fontSize="16" as={icon} />}
<Text
style={{ textDecoration: "none" }}
_focus={{ boxShadow: "none" }}
>
{children}
</Text>
</Flex>
)}
</NavLink>
);
};
export default function Settings() {
return <Outlet />;
}
================================================
FILE: app/routes/projects/$projectId.tsx
================================================
import {
Box,
BoxProps,
Button,
Divider,
Flex,
Grid,
GridItem,
Heading,
HStack,
Icon,
Image,
List,
ListIcon,
ListItem,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Skeleton,
Spacer,
Stack,
Tab,
TabList,
Tabs,
Text,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react";
import { ActionArgs, json, LoaderArgs, SerializeFrom } from "@remix-run/node";
import {
Link as RemixLink,
NavLink,
Outlet,
useFetcher,
useLoaderData,
useMatches,
} from "@remix-run/react";
import { ReactNode, RefObject, useEffect, useRef } from "react";
import { IconType } from "react-icons";
import {
FiChevronDown,
FiChevronUp,
FiGrid,
FiList,
FiSettings,
} from "react-icons/fi";
import logo from "~/images/logo_64.png";
import { getProjectById, getProjectByIds } from "~/models/project.server";
import { requireUser } from "~/session.server";
import { httpResponse, useUser } from "~/utils";
import ColorModeButton from "../home/..lib/ColorModeButton";
import UserMenuButton from "../home/..lib/UserMenuButton";
import {
gitextract_0qb6gbce/ ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── app/ │ ├── context.tsx │ ├── createEmotionCache.ts │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── models/ │ │ ├── api.server.ts │ │ ├── prisma.server.ts │ │ ├── project.server.ts │ │ ├── type.ts │ │ └── user.server.ts │ ├── root.tsx │ ├── routes/ │ │ ├── home/ │ │ │ ├── ..lib/ │ │ │ │ ├── ColorModeButton.tsx │ │ │ │ ├── Layout.tsx │ │ │ │ ├── NotificationButton.tsx │ │ │ │ └── UserMenuButton.tsx │ │ │ ├── logout.tsx │ │ │ ├── settings.tsx │ │ │ ├── signin.tsx │ │ │ └── signup.tsx │ │ ├── index.tsx │ │ ├── mock/ │ │ │ └── $projectId.$.tsx │ │ └── projects/ │ │ ├── $projectId/ │ │ │ ├── activities.tsx │ │ │ ├── apis/ │ │ │ │ ├── ..api.tsx │ │ │ │ ├── ..editor.tsx │ │ │ │ ├── ..postman.tsx │ │ │ │ ├── details.$apiId.tsx │ │ │ │ ├── groups.$groupId.tsx │ │ │ │ └── index.tsx │ │ │ ├── apis.tsx │ │ │ ├── settings/ │ │ │ │ ├── index.tsx │ │ │ │ └── members.tsx │ │ │ └── settings.tsx │ │ ├── $projectId.tsx │ │ └── index.tsx │ ├── session.server.ts │ ├── theme.ts │ ├── ui/ │ │ ├── AceEditor.tsx │ │ ├── Form/ │ │ │ ├── FormCancelButton.tsx │ │ │ ├── FormHInput.tsx │ │ │ ├── FormInput.tsx │ │ │ ├── FormModal.tsx │ │ │ ├── FormSubmitButton.tsx │ │ │ ├── ModalInput.tsx │ │ │ ├── PathInput.tsx │ │ │ └── type.ts │ │ ├── Header.tsx │ │ ├── _AceEditor.tsx │ │ ├── dashboard.css │ │ └── index.ts │ └── utils/ │ ├── hooks.ts │ ├── index.ts │ ├── mock.ts │ └── treeBuilder.ts ├── package.json ├── prisma/ │ ├── schema.prisma │ └── seed.ts ├── remix.config.js ├── remix.env.d.ts └── tsconfig.json
SYMBOL INDEX (92 symbols across 37 files)
FILE: app/context.tsx
type ServerStyleContextData (line 4) | interface ServerStyleContextData {
type ClientStyleContextData (line 14) | interface ClientStyleContextData {
FILE: app/createEmotionCache.ts
function createEmotionCache (line 4) | function createEmotionCache() {
FILE: app/entry.client.tsx
type ClientCacheProviderProps (line 10) | interface ClientCacheProviderProps {
function ClientCacheProvider (line 14) | function ClientCacheProvider({ children }: ClientCacheProviderProps) {
FILE: app/entry.server.tsx
function handleRequest (line 11) | function handleRequest(
FILE: app/models/project.server.ts
type Api (line 82) | type Api = NonNullable<Awaited<ReturnType<typeof findProjectById>>>['api...
type PlainGroup (line 83) | type PlainGroup = NonNullable<Awaited<ReturnType<typeof findProjectById>...
type Group (line 84) | type Group = PlainGroup & {
type Project (line 140) | type Project = NonNullable<Awaited<ReturnType<typeof getProjectById>>>;
FILE: app/models/type.ts
type JsonNode (line 25) | interface JsonNode {
FILE: app/models/user.server.ts
function getUserById (line 6) | async function getUserById(id: User["id"]) {
function getUserByName (line 10) | async function getUserByName(name: User["name"]) {
function getUserByEmail (line 14) | async function getUserByEmail(email: User["email"]) {
function getUserInfoByIds (line 18) | async function getUserInfoByIds(ids: string[]) {
function createUser (line 33) | async function createUser(
function verifyLogin (line 49) | async function verifyLogin(
FILE: app/root.tsx
type DocumentProps (line 50) | interface DocumentProps {
function App (line 97) | function App() {
FILE: app/routes/home/..lib/ColorModeButton.tsx
function ColorModeButton (line 4) | function ColorModeButton(props: ButtonProps) {
FILE: app/routes/home/..lib/Layout.tsx
function Layout (line 36) | function Layout({ children }: PropsWithChildren) {
function Header (line 46) | function Header() {
function GuestMenuButtons (line 124) | function GuestMenuButtons() {
type NavItem (line 290) | interface NavItem {
constant NAV_ITEMS (line 297) | const NAV_ITEMS: Array<NavItem> = [];
function Footer (line 330) | function Footer() {
FILE: app/routes/home/..lib/UserMenuButton.tsx
function UserMenuButton (line 15) | function UserMenuButton({
FILE: app/routes/home/logout.tsx
function action (line 6) | async function action({ request }: ActionArgs) {
function loader (line 10) | async function loader({ request }: LoaderArgs) {
FILE: app/routes/home/settings.tsx
function Settings (line 113) | function Settings() {
FILE: app/routes/home/signin.tsx
function loader (line 33) | async function loader({ request }: LoaderArgs) {
function action (line 39) | async function action({ request }: ActionArgs) {
function Login (line 85) | function Login() {
FILE: app/routes/home/signup.tsx
function loader (line 22) | async function loader({ request }: LoaderArgs) {
function action (line 28) | async function action({ request }: ActionArgs) {
function SignUp (line 95) | function SignUp() {
FILE: app/routes/index.tsx
function links (line 7) | function links() {
function loader (line 20) | async function loader({ request }: LoaderArgs) {
FILE: app/routes/mock/$projectId.$.tsx
function findPathForRule (line 42) | function findPathForRule(path: string, rules: string[]) {
function matchApi (line 55) | function matchApi(apiPath: string, apiRule: string) {
FILE: app/routes/projects/$projectId.tsx
function Layout (line 101) | function Layout({ children }: { children: ReactNode }) {
type SidebarProps (line 111) | interface SidebarProps extends BoxProps {}
type SubMenuItemProps (line 316) | interface SubMenuItemProps extends BoxProps {
FILE: app/routes/projects/$projectId/activities.tsx
function Activities (line 1) | function Activities() {
FILE: app/routes/projects/$projectId/apis.tsx
type Action (line 93) | enum Action {
function SideNav (line 260) | function SideNav() {
function generator (line 718) | function generator(method: string) {
function CatchBoundary (line 772) | function CatchBoundary() {
function Apis (line 896) | function Apis() {
FILE: app/routes/projects/$projectId/apis/..editor.tsx
type JsonNodeFormElem (line 99) | type JsonNodeFormElem = Omit<
type JsonNodeForm (line 105) | type JsonNodeForm = JsonNodeFormElem & {
type JsonNodeTransformedElem (line 109) | type JsonNodeTransformedElem = Omit<JsonNodeFormElem, "isRequired"> & {
type JsonNodeTransformed (line 112) | type JsonNodeTransformed = JsonNodeTransformedElem & {
FILE: app/routes/projects/$projectId/apis/details.$apiId.tsx
function ApiInfo (line 61) | function ApiInfo() {
FILE: app/routes/projects/$projectId/apis/groups.$groupId.tsx
function ApiGroup (line 85) | function ApiGroup() {
FILE: app/routes/projects/$projectId/apis/index.tsx
function ApiOverview (line 36) | function ApiOverview() {
FILE: app/routes/projects/$projectId/settings.tsx
function SideNav (line 26) | function SideNav() {
type NavItemProps (line 59) | interface NavItemProps extends FlexProps {
function Settings (line 99) | function Settings() {
FILE: app/routes/projects/$projectId/settings/members.tsx
function CatchBoundary (line 231) | function CatchBoundary() {
FILE: app/session.server.ts
constant USER_SESSION_KEY (line 21) | const USER_SESSION_KEY = "userId";
function getSession (line 23) | async function getSession(request: Request) {
function getUserId (line 28) | async function getUserId(
function getUser (line 39) | async function getUser(request: Request) {
function requireUserId (line 51) | async function requireUserId(
function requireUser (line 63) | async function requireUser(request: Request) {
function createUserSession (line 72) | async function createUserSession({
function logout (line 96) | async function logout(request: Request) {
FILE: app/ui/Form/FormCancelButton.tsx
type FormCancelButtonProps (line 4) | interface FormCancelButtonProps extends ButtonProps {}
function FormCancelButton (line 6) | function FormCancelButton({
FILE: app/ui/Form/FormHInput.tsx
type FormInputProps (line 16) | interface FormInputProps
FILE: app/ui/Form/FormInput.tsx
type FormInputProps (line 18) | interface FormInputProps
FILE: app/ui/Form/FormModal.tsx
type FormModalProps (line 10) | type FormModalProps<DataType> = Omit<
function FormModal (line 17) | function FormModal<DataType>({
FILE: app/ui/Form/FormSubmitButton.tsx
type FormButtonProps (line 4) | interface FormButtonProps extends ButtonProps {}
function FormSubmitButton (line 6) | function FormSubmitButton({
FILE: app/ui/Form/PathInput.tsx
type PathInputProps (line 11) | interface PathInputProps extends InputProps {
FILE: app/ui/Form/type.ts
type MinimalInputProps (line 1) | type MinimalInputProps = {
FILE: app/utils/index.ts
constant DEFAULT_REDIRECT (line 9) | const DEFAULT_REDIRECT = "/";
function safeRedirect (line 18) | function safeRedirect(
function useMatchesData (line 39) | function useMatchesData(
function isUser (line 50) | function isUser(user: any): user is User {
function useOptionalUser (line 54) | function useOptionalUser(): User | undefined {
function useUser (line 62) | function useUser(): User {
function useUrl (line 72) | function useUrl() {
FILE: app/utils/treeBuilder.ts
class TreeBuilder (line 4) | class TreeBuilder {
method constructor (line 9) | constructor(rootId: ItemId, data: Group | Api | null) {
method withLeaf (line 17) | withLeaf(id: ItemId, data: Group | Api | null) {
method withSubTree (line 24) | withSubTree(tree: TreeBuilder) {
method build (line 42) | build() {
method _addItemToRoot (line 49) | _addItemToRoot(id: ItemId) {
FILE: prisma/seed.ts
function main (line 3) | async function main() {
Condensed preview — 62 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (254K chars).
[
{
"path": ".gitignore",
"chars": 1659,
"preview": "# Build\nbuild/\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports "
},
{
"path": ".npmrc",
"chars": 22,
"preview": "legacy-peer-deps=true\n"
},
{
"path": "LICENSE",
"chars": 11357,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "README.md",
"chars": 2703,
"preview": "<h1 align=\"center\">\n <a href=\"https://apiannie.com\">\n <img alt=\"ApiAnnie\" src=\"https://user-images.githubuserco"
},
{
"path": "app/context.tsx",
"chars": 408,
"preview": "// context.tsx\nimport React, { createContext } from \"react\";\n\nexport interface ServerStyleContextData {\n key: string;\n "
},
{
"path": "app/createEmotionCache.ts",
"chars": 155,
"preview": "// createEmotionCache.ts\nimport createCache from \"@emotion/cache\";\n\nexport default function createEmotionCache() {\n ret"
},
{
"path": "app/entry.client.tsx",
"chars": 840,
"preview": "// entry.client.tsx\nimport React, { useState } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport { Ca"
},
{
"path": "app/entry.server.tsx",
"chars": 1405,
"preview": "// entry.server.tsx\nimport { renderToString } from \"react-dom/server\";\nimport { CacheProvider } from \"@emotion/react\";\ni"
},
{
"path": "app/models/api.server.ts",
"chars": 4077,
"preview": "import { RequestParam } from \".prisma/client\";\nimport { ApiData, RequestMethod } from \"@prisma/client\";\nimport { prisma "
},
{
"path": "app/models/prisma.server.ts",
"chars": 376,
"preview": "import { PrismaClient } from \"@prisma/client\";\n\nlet prisma: PrismaClient;\ndeclare global {\n var __db: PrismaClient | un"
},
{
"path": "app/models/project.server.ts",
"chars": 6886,
"preview": "import { ProjectUserRole, User } from '@prisma/client';\nimport invariant from 'tiny-invariant';\nimport { checkRole } fro"
},
{
"path": "app/models/type.ts",
"chars": 744,
"preview": "import {\n ParamType,\n Prisma,\n ProjectUserRole,\n RequestMethod,\n} from \"@prisma/client\";\n\nexport const JsonNodeType "
},
{
"path": "app/models/user.server.ts",
"chars": 1946,
"preview": "import { User } from \"@prisma/client\";\nimport { prisma } from \"./prisma.server\";\nimport bcrypt from \"bcryptjs\";\nexport t"
},
{
"path": "app/root.tsx",
"chars": 2805,
"preview": "// root.tsx\nimport {\n ChakraProvider,\n cookieStorageManagerSSR,\n localStorageManager,\n useConst,\n} from \"@chakra-ui/"
},
{
"path": "app/routes/home/..lib/ColorModeButton.tsx",
"chars": 385,
"preview": "import { MoonIcon, SunIcon } from \"@chakra-ui/icons\";\nimport { Button, ButtonProps, useColorMode } from \"@chakra-ui/reac"
},
{
"path": "app/routes/home/..lib/Layout.tsx",
"chars": 8963,
"preview": "import {\n ChevronDownIcon,\n ChevronRightIcon,\n CloseIcon,\n HamburgerIcon,\n} from \"@chakra-ui/icons\";\nimport {\n Box,"
},
{
"path": "app/routes/home/..lib/NotificationButton.tsx",
"chars": 868,
"preview": "import {\n Button,\n ButtonProps,\n Center,\n Icon,\n Popover,\n PopoverArrow,\n PopoverBody,\n PopoverCloseButton,\n Po"
},
{
"path": "app/routes/home/..lib/UserMenuButton.tsx",
"chars": 1136,
"preview": "import {\n Avatar,\n AvatarProps,\n Box,\n Button,\n Menu,\n MenuButton,\n MenuDivider,\n MenuItem,\n MenuList,\n Text,\n"
},
{
"path": "app/routes/home/logout.tsx",
"chars": 321,
"preview": "import type { ActionArgs, LoaderArgs } from \"@remix-run/node\";\nimport { redirect } from \"@remix-run/node\";\n\nimport { log"
},
{
"path": "app/routes/home/settings.tsx",
"chars": 6191,
"preview": "import {\n Flex,\n Heading,\n Text,\n Stack,\n useColorModeValue,\n Box,\n Container,\n Center,\n useToast,\n} from \"@cha"
},
{
"path": "app/routes/home/signin.tsx",
"chars": 4905,
"preview": "import {\n Box,\n Button,\n Checkbox,\n Flex,\n Heading,\n InputRightElement,\n Stack,\n Text,\n useColorModeValue,\n} fr"
},
{
"path": "app/routes/home/signup.tsx",
"chars": 4583,
"preview": "import {\n Box,\n Flex,\n Heading,\n Stack,\n Text,\n useColorModeValue,\n} from \"@chakra-ui/react\";\n\nimport { ActionArgs"
},
{
"path": "app/routes/index.tsx",
"chars": 2312,
"preview": "import { Box, Button, Container, Heading, Stack, Text } from \"@chakra-ui/react\";\nimport { json, LoaderArgs, redirect } f"
},
{
"path": "app/routes/mock/$projectId.$.tsx",
"chars": 3092,
"preview": "import { RequestMethod } from \"@prisma/client\";\nimport { ActionArgs, json, LoaderArgs, Response } from \"@remix-run/node\""
},
{
"path": "app/routes/projects/$projectId/activities.tsx",
"chars": 64,
"preview": "export default function Activities() {\n return \"Activities\";\n}\n"
},
{
"path": "app/routes/projects/$projectId/apis/..api.tsx",
"chars": 11478,
"preview": "import {\n Box,\n BoxProps,\n Center,\n Flex,\n Grid,\n Heading,\n Icon,\n Link,\n Table,\n TableContainer,\n Tbody,\n T"
},
{
"path": "app/routes/projects/$projectId/apis/..editor.tsx",
"chars": 32839,
"preview": "import {\n Box,\n BoxProps,\n Button,\n Center,\n Checkbox,\n Container,\n Divider,\n Flex,\n HStack,\n Icon,\n IconButt"
},
{
"path": "app/routes/projects/$projectId/apis/..postman.tsx",
"chars": 20436,
"preview": "import { ApiData, RequestParam } from \".prisma/client\";\nimport {\n Box,\n Button,\n Center,\n Checkbox,\n Divider,\n Fle"
},
{
"path": "app/routes/projects/$projectId/apis/details.$apiId.tsx",
"chars": 2397,
"preview": "import { TabPanel, TabPanels } from \"@chakra-ui/react\";\nimport { ActionArgs, json, LoaderArgs } from \"@remix-run/node\";\n"
},
{
"path": "app/routes/projects/$projectId/apis/groups.$groupId.tsx",
"chars": 4123,
"preview": "import {\n Box,\n Center,\n Input,\n TabPanel,\n TabPanels,\n Textarea,\n useToast,\n VStack,\n} from \"@chakra-ui/react\";"
},
{
"path": "app/routes/projects/$projectId/apis/index.tsx",
"chars": 3480,
"preview": "import {\n Box,\n Button,\n Center,\n Divider,\n Flex,\n Heading,\n Icon,\n Link,\n Spacer,\n Table,\n TableContainer,\n "
},
{
"path": "app/routes/projects/$projectId/apis.tsx",
"chars": 23180,
"preview": "import Tree, {\n ItemId,\n moveItemOnTree,\n mutateTree,\n RenderItemParams,\n TreeData,\n TreeDestinationPosition,\n Tr"
},
{
"path": "app/routes/projects/$projectId/settings/index.tsx",
"chars": 11116,
"preview": "import {\n Box,\n Button,\n Container,\n Flex,\n HStack,\n Text,\n Spacer,\n useColorModeValue,\n useToast,\n Modal,\n M"
},
{
"path": "app/routes/projects/$projectId/settings/members.tsx",
"chars": 14396,
"preview": "import {\n Box,\n Button,\n Divider,\n Flex,\n Icon,\n Spacer,\n Table,\n TableContainer,\n Tbody,\n Td,\n Th,\n Thead,\n"
},
{
"path": "app/routes/projects/$projectId/settings.tsx",
"chars": 2274,
"preview": "import {\n Box,\n Divider,\n Flex,\n FlexProps,\n Grid,\n Icon,\n Link,\n Spacer,\n Text,\n useColorModeValue,\n VStack,"
},
{
"path": "app/routes/projects/$projectId.tsx",
"chars": 9661,
"preview": "import {\n Box,\n BoxProps,\n Button,\n Divider,\n Flex,\n Grid,\n GridItem,\n Heading,\n HStack,\n Icon,\n Image,\n Lis"
},
{
"path": "app/routes/projects/index.tsx",
"chars": 6488,
"preview": "import {\n Box,\n Button,\n Container,\n Divider,\n Heading,\n HStack,\n Icon,\n Image,\n Input,\n InputGroup,\n InputLe"
},
{
"path": "app/session.server.ts",
"chars": 2541,
"preview": "import { createCookieSessionStorage, redirect } from \"@remix-run/node\";\nimport { ObjectID } from \"bson\";\nimport invarian"
},
{
"path": "app/theme.ts",
"chars": 324,
"preview": "// theme.ts\n\n// 1. import `extendTheme` function\nimport { extendTheme, type ThemeConfig } from \"@chakra-ui/react\";\n\n// 2"
},
{
"path": "app/ui/AceEditor.tsx",
"chars": 339,
"preview": "import { Spinner } from \"@chakra-ui/react\";\nimport { lazy, Suspense } from \"react\";\nimport { IAceEditorProps } from \"rea"
},
{
"path": "app/ui/Form/FormCancelButton.tsx",
"chars": 368,
"preview": "import { Button, ButtonProps } from \"@chakra-ui/react\";\nimport { useIsSubmitting } from \"remix-validated-form\";\n\nexport "
},
{
"path": "app/ui/Form/FormHInput.tsx",
"chars": 1634,
"preview": "import {\n Alert,\n AlertIcon,\n Box,\n Flex,\n FormControl,\n FormControlProps,\n FormLabel,\n forwardRef,\n Input,\n I"
},
{
"path": "app/ui/Form/FormInput.tsx",
"chars": 1463,
"preview": "import {\n Alert,\n AlertIcon,\n Box,\n Checkbox,\n ComponentWithAs,\n FormControl,\n FormControlProps,\n FormLabel,\n f"
},
{
"path": "app/ui/Form/FormModal.tsx",
"chars": 1493,
"preview": "import { Modal, ModalProps } from \"@chakra-ui/react\";\nimport { useFetcher } from \"@remix-run/react\";\nimport { useEffect "
},
{
"path": "app/ui/Form/FormSubmitButton.tsx",
"chars": 475,
"preview": "import { Button, ButtonProps } from \"@chakra-ui/react\";\nimport { useIsSubmitting } from \"remix-validated-form\";\n\nexport "
},
{
"path": "app/ui/Form/ModalInput.tsx",
"chars": 1919,
"preview": "import {\n Button,\n Icon,\n Input,\n InputGroup,\n InputProps,\n InputRightElement,\n Modal,\n ModalBody,\n ModalCloseB"
},
{
"path": "app/ui/Form/PathInput.tsx",
"chars": 978,
"preview": "import {\n Input,\n InputGroup,\n InputLeftAddon,\n InputProps,\n Select,\n} from \"@chakra-ui/react\";\nimport { RequestMet"
},
{
"path": "app/ui/Form/type.ts",
"chars": 208,
"preview": "export type MinimalInputProps = {\n onChange?: (...args: any[]) => void;\n onBlur?: (...args: any[]) => void;\n de"
},
{
"path": "app/ui/Header.tsx",
"chars": 264,
"preview": "import { Heading, HeadingProps } from \"@chakra-ui/react\";\n\nconst Header = (props: HeadingProps) => {\n return (\n <Hea"
},
{
"path": "app/ui/_AceEditor.tsx",
"chars": 877,
"preview": "import AceEditor, { IAceEditorProps } from \"react-ace\";\n\nimport \"ace-builds/src-noconflict/mode-plain_text\";\nimport \"ace"
},
{
"path": "app/ui/dashboard.css",
"chars": 38,
"preview": "body {\n /* overflow-y: hidden; */\n}"
},
{
"path": "app/ui/index.ts",
"chars": 567,
"preview": "import { lazy } from \"react\";\nimport FormCancelButton from \"./Form/FormCancelButton\";\nimport FormHInput from \"./Form/For"
},
{
"path": "app/utils/hooks.ts",
"chars": 2131,
"preview": "import { parsePath } from \"./index\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport const use"
},
{
"path": "app/utils/index.ts",
"chars": 3430,
"preview": "import { ProjectUserRole } from \"@prisma/client\";\nimport { json } from \"@remix-run/node\";\nimport { useMatches } from \"@r"
},
{
"path": "app/utils/mock.ts",
"chars": 1273,
"preview": "import Chance from \"chance\";\nimport { JsonNode } from \"~/models/type\";\n\nconst chance = new Chance();\n\nconst mockJsonHelp"
},
{
"path": "app/utils/treeBuilder.ts",
"chars": 1584,
"preview": "import { ItemId, TreeItem } from \"@atlaskit/tree\";\nimport { Api, Group } from \"~/models/project.server\";\n\nexport default"
},
{
"path": "package.json",
"chars": 1706,
"preview": "{\n \"private\": true,\n \"sideEffects\": false,\n \"scripts\": {\n \"build\": \"remix build\",\n \"deploy\": \"fly deploy --remo"
},
{
"path": "prisma/schema.prisma",
"chars": 3643,
"preview": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator clien"
},
{
"path": "prisma/seed.ts",
"chars": 179,
"preview": "import { PrismaClient } from \"@prisma/client\";\n\nasync function main() {\n let prisma = new PrismaClient();\n prisma.$con"
},
{
"path": "remix.config.js",
"chars": 282,
"preview": "/** @type {import('@remix-run/dev').AppConfig} */\nmodule.exports = {\n ignoredRouteFiles: [\"**/.*\"],\n // appDirectory: "
},
{
"path": "remix.env.d.ts",
"chars": 91,
"preview": "/// <reference types=\"@remix-run/dev\" />\n/// <reference types=\"@remix-run/node/globals\" />\n"
},
{
"path": "tsconfig.json",
"chars": 579,
"preview": "{\n \"include\": [\"remix.env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \"**/..*.tsx\"],\n \"compilerOptions\": {\n \"module\": \"esnext\",\n "
}
]
About this extraction
This page contains the full source code of the apiannie/apiannie GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 62 files (232.9 KB), approximately 59.3k tokens, and a symbol index with 92 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.