[FEATURE] New Project Display (#1)
Reviewed-on: https://forgejo.dunemask.dev///elysium/cairo/pulls/1 Co-authored-by: Dunemask <dunemask@gmail.com> Co-committed-by: Dunemask <dunemask@gmail.com>
This commit is contained in:
parent
c50c4ef647
commit
a293eadbde
18 changed files with 247 additions and 45 deletions
|
@ -31,4 +31,8 @@ export default class ProjectTableService extends TableService {
|
|||
throw ProjectErrors.ConflictNonUnique;
|
||||
});
|
||||
}
|
||||
|
||||
async listChildren(parentProjectId: string) {
|
||||
return this.pg.project.findMany({ where: { parentProject: parentProjectId } });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ type CreateCRC = ContractRouteContext<{
|
|||
RequestParamsContract: typeof ProjectContract.ProjectParams;
|
||||
}>;
|
||||
|
||||
type ListCRC = ContractRouteContext<{ RequestParamsContract: typeof ProjectContract.ProjectParams }>;
|
||||
|
||||
export default class ProjectController extends VixpressController {
|
||||
declare pg: PostgresService;
|
||||
constructor(app: Express) {
|
||||
|
@ -45,4 +47,13 @@ export default class ProjectController extends VixpressController {
|
|||
const publicKey = await decrypt(kp.encryptedPublicKey, config.SigningOptions.Keys.KeyPair);
|
||||
return { user: usr, project: proj, publicKey };
|
||||
}
|
||||
|
||||
async list(crc: any): Promise<CProjectContract["ListProjects"]> {
|
||||
const { project: parentProject } = crc.req as UserRequest;
|
||||
const projectPromise = this.pg.project.byId(parentProject.id);
|
||||
const childProjectsPromise = this.pg.project.listChildren(parentProject.id);
|
||||
const [project, childProjects] = await Promise.all([projectPromise, childProjectsPromise]);
|
||||
if (!project) throw ProjectErrors.NotFoundProject;
|
||||
return { project, childProjects };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,9 +14,11 @@ export class ProjectRoute extends VixpressRoute {
|
|||
|
||||
// Configuration
|
||||
const projCreate = { ...cBase, reqBody: ProjectContract.Create };
|
||||
const projList = { reqParams: ProjectContract.ProjectParams, resBody: ProjectContract.ListProjects };
|
||||
// Middleware
|
||||
|
||||
// Routes
|
||||
router.post("/create", RouteGuard.MangeProjectsCreate, contract(projController.create, projCreate));
|
||||
router.get("/list", RouteGuard.ManageProjectsRead, contract(projController.list, projList));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,14 +3,30 @@ import * as y from "yup";
|
|||
import { DatabaseContract } from "./database.contracts";
|
||||
// ====================================== Reused Contracts ======================================
|
||||
|
||||
const Project = y.object({
|
||||
id: y.string().required(),
|
||||
slug: y.string().required(),
|
||||
parentProject: y.string().required(),
|
||||
name: y.string().nullable(),
|
||||
});
|
||||
|
||||
// ====================================== Response Contracts ======================================
|
||||
|
||||
export const ProjectContractRes = defineContractExport("CProjectContractRes", {
|
||||
CreateResponse: y.object({
|
||||
project: DatabaseContract.Project,
|
||||
user: DatabaseContract.User,
|
||||
publicKey: y.string().required(),
|
||||
}),
|
||||
CreateResponse: y
|
||||
.object({
|
||||
project: DatabaseContract.Project,
|
||||
user: DatabaseContract.User,
|
||||
publicKey: y.string().required(),
|
||||
})
|
||||
.required(),
|
||||
Project: Project.required(),
|
||||
ListProjects: y
|
||||
.object({
|
||||
project: Project.required(),
|
||||
childProjects: y.array(Project).required(),
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
|
||||
// ====================================== Request Contracts ======================================
|
||||
|
|
|
@ -18,6 +18,7 @@ export class ProjectErrors {
|
|||
static readonly BadRequestProjectIncomplete = new ClientError(400, "Project incomplete!");
|
||||
static readonly UnexpectedRootUserError = new ClientError(500, "Error creating root user!");
|
||||
static readonly ConflictNonUnique = new ClientError(409, "Slug already taken!");
|
||||
static readonly NotFoundProject = new ClientError(404, "Project not found!");
|
||||
}
|
||||
|
||||
export class KeyPairErrors {
|
||||
|
|
29
package-lock.json
generated
29
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "cairo",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cairo",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"license": "LGPL-2.1",
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
|
@ -16,6 +16,7 @@
|
|||
"@mui/material": "^5.16.7",
|
||||
"@prisma/client": "^5.18.0",
|
||||
"@sendgrid/mail": "^8.1.3",
|
||||
"@tanstack/react-query": "^5.53.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cron": "^3.1.7",
|
||||
"dotenv": "^16.4.5",
|
||||
|
@ -2709,6 +2710,30 @@
|
|||
"node": ">=12.*"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.53.3.tgz",
|
||||
"integrity": "sha512-ZfjAgd7NpqDx0e4aYBt7EmS2enbulPrJwowTy+mayRE93WUUH+sIYHun1TdRjpGwDPMNNZ5D6goh7n3CwoO+HA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.53.3.tgz",
|
||||
"integrity": "sha512-286mN/91CeM7vC6CZFLKYDHSw+WyMX6ekIvzoTbpM4xyPb99VSyCKPLyPgaOatKqYm6ooMBquSq9NGRdKgsJfg==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.53.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
|
@ -45,13 +45,14 @@
|
|||
"vite-tsconfig-paths": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dunemask/vix": "^0.0.1-alpha.0",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@dunemask/vix": "^0.0.1-alpha.0",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/material": "^5.16.7",
|
||||
"@prisma/client": "^5.18.0",
|
||||
"@sendgrid/mail": "^8.1.3",
|
||||
"@tanstack/react-query": "^5.53.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cron": "^3.1.7",
|
||||
"dotenv": "^16.4.5",
|
||||
|
|
19
src/App.tsx
19
src/App.tsx
|
@ -4,18 +4,25 @@ import { ChakraProvider } from "@chakra-ui/react";
|
|||
import useInitHooks from "@src/hooks/init-hooks";
|
||||
import theme from "@src/util/theme";
|
||||
import { AuthProvider } from "@src/ctx/AuthContext";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import Viewport from "./Viewport";
|
||||
import { ProjectProvider } from "./ctx/ProjectContext";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
|
||||
export default function App() {
|
||||
const qc = new QueryClient();
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<InitProvider>
|
||||
<Viewport />
|
||||
</InitProvider>
|
||||
</AuthProvider>
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthProvider>
|
||||
<ProjectProvider>
|
||||
<InitProvider>
|
||||
<Viewport />
|
||||
</InitProvider>
|
||||
</ProjectProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</ChakraProvider>
|
||||
);
|
||||
|
|
|
@ -19,7 +19,7 @@ declare type Portal = { path: Links; view: ReactNode };
|
|||
function Auth(props: { view: (() => ReactNode) | LazyExoticComponent<() => ReactNode> }) {
|
||||
const { auth, loading, initialized } = useAuth();
|
||||
const Component = props.view;
|
||||
if (!loading && !initialized && !!auth) return <Component />;
|
||||
if (!loading && initialized && !!auth) return <Component />;
|
||||
return <AutoRedirect />;
|
||||
}
|
||||
|
||||
|
|
16
src/components/projects/ProjectTile.tsx
Normal file
16
src/components/projects/ProjectTile.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Box, Flex, Skeleton, Text } from "@chakra-ui/react";
|
||||
import { CProjectContract } from "@lib/contracts/project.contracts";
|
||||
|
||||
export default function ProjectTile(props: { project: CProjectContract["Project"] }) {
|
||||
return (
|
||||
<Box w="210px" h="210px" bg="background.paper" _hover={{ opacity: ".9", cursor: "pointer" }} borderRadius=".5rem">
|
||||
<Text>{props.project.name ?? props.project.slug}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectTileSkeleton() {
|
||||
return (
|
||||
<Skeleton w="210px" h="210px" bg="background.paper" _hover={{ opacity: ".9", cursor: "pointer" }} borderRadius=".5rem" />
|
||||
);
|
||||
}
|
|
@ -4,8 +4,8 @@ import { Policy, PolicyString } from "@lib/Policies";
|
|||
import { Resource } from "@lib/vix/AppResources";
|
||||
import { CDatabaseContract } from "@lib/contracts/database.contracts";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { PROJECT_CONTEXT_INITIAL_STATE } from "./ProjectContext";
|
||||
|
||||
const project = import.meta.env.VITE_CAIRO_PROJECT as string;
|
||||
const credentialApiPath = `auth/credentials`;
|
||||
|
||||
export enum AuthStorageKeys {
|
||||
|
@ -154,7 +154,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
async function getPolicies(token: string): Promise<Policy[]> {
|
||||
const extraHeaders = { Authorization: `Bearer ${token}` };
|
||||
const projectId = !!search.get("projectId") ? search.get("projectId") : project;
|
||||
const projectId = !!search.get("projectId") ? search.get("projectId") : PROJECT_CONTEXT_INITIAL_STATE.projectId;
|
||||
const apiPath = `/${projectId}/${credentialApiPath}`;
|
||||
const credentials = await apiRequest({ subpath: apiPath, jsonify: true, extraHeaders });
|
||||
if (!credentials) return [];
|
||||
|
@ -183,7 +183,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
async function fetchCredentials(token: string): Promise<[user: CDatabaseContract["User"], rp: Policy[]]> {
|
||||
const extraHeaders = getAuthHeader(token);
|
||||
const projectId = !!search.get("projectId") ? search.get("projectId") : project;
|
||||
const projectId = !!search.get("projectId") ? search.get("projectId") : PROJECT_CONTEXT_INITIAL_STATE.projectId;
|
||||
const apiPath = `/${projectId}/${credentialApiPath}`;
|
||||
const credentials = await apiRequest({ subpath: apiPath, jsonify: true, extraHeaders });
|
||||
if (!credentials) throw Error("Could not authenticate!");
|
||||
|
|
29
src/ctx/ProjectContext.tsx
Normal file
29
src/ctx/ProjectContext.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { createContext, ReactNode, useContext, useState } from "react";
|
||||
|
||||
interface ProjectState {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface ProjectContextType extends ProjectState {
|
||||
setProjectId: (v: string) => void;
|
||||
}
|
||||
|
||||
export const PROJECT_CONTEXT_INITIAL_STATE: ProjectContextType = {
|
||||
projectId: import.meta.env.VITE_CAIRO_PROJECT as string,
|
||||
setProjectId: () => void 0,
|
||||
};
|
||||
|
||||
const ProjectContext = createContext<ProjectContextType>(PROJECT_CONTEXT_INITIAL_STATE);
|
||||
|
||||
export const useProjectContext = () => useContext<ProjectContextType>(ProjectContext);
|
||||
|
||||
export const ProjectProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [projectId, setProjectId] = useState<string>(PROJECT_CONTEXT_INITIAL_STATE.projectId);
|
||||
|
||||
const context: ProjectContextType = {
|
||||
projectId,
|
||||
setProjectId,
|
||||
};
|
||||
|
||||
return <ProjectContext.Provider value={context}>{children}</ProjectContext.Provider>;
|
||||
};
|
|
@ -1,9 +1,37 @@
|
|||
import { useAuth } from "@src/ctx/AuthContext";
|
||||
import { PROJECT_CONTEXT_INITIAL_STATE, useProjectContext } from "@src/ctx/ProjectContext";
|
||||
import { useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function useInitHooks() {
|
||||
const { authInit } = useAuth();
|
||||
const { authInit, auth } = useAuth();
|
||||
const { setProjectId } = useProjectContext();
|
||||
const [search] = useSearchParams();
|
||||
|
||||
useEffect(function initHooks() {
|
||||
authInit();
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
function authInitHooks() {
|
||||
if (!auth) return;
|
||||
},
|
||||
[auth],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function paramHooks() {
|
||||
projectIdParamHook();
|
||||
},
|
||||
[search],
|
||||
);
|
||||
|
||||
async function nonAuthInitHooks() {}
|
||||
|
||||
async function authInitHooks() {}
|
||||
|
||||
function projectIdParamHook() {
|
||||
const projectId = !!search.get("projectId") ? (search.get("projectId") as string) : PROJECT_CONTEXT_INITIAL_STATE.projectId;
|
||||
setProjectId(projectId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,20 +3,17 @@ import { CAuthContract, CProjectContract } from "@vix/ContractTypes";
|
|||
import { apiRequest } from "@dunemask/vix/bridge";
|
||||
import { authenticatedApiRequest } from "./requests";
|
||||
|
||||
export const getProjectAuthVerify = (project: string) =>
|
||||
authenticatedApiRequest({ subpath: `/${project}/auth/verify`, method: "GET", jsonify: true });
|
||||
export const getProjectAuthVerify = (project: string) =>
|
||||
authenticatedApiRequest({subpath: `/${project}/auth/verify`, method:"GET", jsonify: true});
|
||||
|
||||
export const postProjectAuthLogin = (project: string, login: CAuthContract["Login"]) =>
|
||||
apiRequest<CAuthContract["LoginCredentials"]>({
|
||||
subpath: `/${project}/auth/login`,
|
||||
method: "POST",
|
||||
json: login,
|
||||
jsonify: true,
|
||||
});
|
||||
export const postProjectAuthLogin = (project: string,login: CAuthContract["Login"]) =>
|
||||
apiRequest<CAuthContract["LoginCredentials"]>({subpath: `/${project}/auth/login`, method:"POST", json: login, jsonify: true});
|
||||
|
||||
export const getProjectAuthCredentials = (project: string) =>
|
||||
authenticatedApiRequest<CAuthContract["Credentials"]>({
|
||||
subpath: `/${project}/auth/credentials`,
|
||||
method: "GET",
|
||||
jsonify: true,
|
||||
});
|
||||
export const getProjectAuthCredentials = (project: string) =>
|
||||
authenticatedApiRequest<CAuthContract["Credentials"]>({subpath: `/${project}/auth/credentials`, method:"GET", jsonify: true});
|
||||
|
||||
export const postProjectCreate = (project: string,create: CProjectContract["Create"]) =>
|
||||
authenticatedApiRequest({subpath: `/${project}/create`, method:"POST", json: create, jsonify: true});
|
||||
|
||||
export const getProjectList = (project: string) =>
|
||||
authenticatedApiRequest<CProjectContract["ListProjects"]>({subpath: `/${project}/list`, method:"GET", jsonify: true});
|
15
src/util/api/queries.ts
Normal file
15
src/util/api/queries.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getProjectList } from "./GeneratedRequests";
|
||||
|
||||
export enum QueryKeys {
|
||||
ProjectList = "ProjectList",
|
||||
}
|
||||
|
||||
export const useMutator = (apiRequest: (...args: any) => Promise<any>, invalidate: string[]) => {
|
||||
const qc = useQueryClient();
|
||||
const mutate = async (...args: any) => apiRequest(...args).then(() => qc.invalidateQueries({ queryKey: invalidate }));
|
||||
return mutate;
|
||||
};
|
||||
|
||||
export const useProjectList = (projectId: string) =>
|
||||
useQuery({ queryKey: [QueryKeys.ProjectList], queryFn: () => getProjectList(projectId) });
|
|
@ -1,9 +1,9 @@
|
|||
import { apiRequest, ApiRequestArgs } from "@dunemask/vix/bridge";
|
||||
import { getAuthHeader } from "@src/ctx/AuthContext";
|
||||
import { getActiveUserToken, getAuthHeader } from "@src/ctx/AuthContext";
|
||||
|
||||
export async function authenticatedApiRequest<K = any>(apiRequestArgs: ApiRequestArgs): Promise<K> {
|
||||
const extraHeaders = apiRequestArgs.extraHeaders ?? {};
|
||||
const authHeaders = getAuthHeader();
|
||||
const authHeaders = getAuthHeader(getActiveUserToken());
|
||||
apiRequestArgs.extraHeaders = { ...extraHeaders, ...authHeaders };
|
||||
return apiRequest(apiRequestArgs);
|
||||
}
|
||||
|
|
|
@ -11,16 +11,15 @@ import { PasswordInput } from "@src/components/common/Inputs";
|
|||
import { ResourcePolicyType } from "@dunemask/vix/util";
|
||||
import { postProjectAuthLogin } from "@src/util/api/GeneratedRequests";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
const project = import.meta.env.VITE_CAIRO_PROJECT as string;
|
||||
import { useProjectContext } from "@src/ctx/ProjectContext";
|
||||
|
||||
export default function AuthenticateView() {
|
||||
const { auth, login } = useAuth();
|
||||
const { projectId } = useProjectContext();
|
||||
const autoRedirect = useAutoRedirect();
|
||||
const [search] = useSearchParams();
|
||||
const [identity, setIdentity] = useState<string>("");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const projectId = search.get("projectId") ?? project;
|
||||
|
||||
const identityChange = (e: SyntheticEvent) => setIdentity((e.target as HTMLInputElement).value);
|
||||
|
||||
|
@ -52,6 +51,7 @@ export default function AuthenticateView() {
|
|||
function detectEnter(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") submitCredentials();
|
||||
}
|
||||
|
||||
if (auth) return <Navigate to={rootLink(Links.AutoRedirect)} />;
|
||||
return (
|
||||
<Box width="100%" height="90vh" display="flex" alignItems="center" justifyContent="center">
|
||||
|
|
|
@ -1,11 +1,61 @@
|
|||
import { Flex, Text, Center } from "@chakra-ui/react";
|
||||
import { Flex, Text, Center, Box, Skeleton, Stack } from "@chakra-ui/react";
|
||||
import ProjectTile, { ProjectTileSkeleton } from "@src/components/projects/ProjectTile";
|
||||
import { useProjectContext } from "@src/ctx/ProjectContext";
|
||||
import { useProjectList } from "@src/util/api/queries";
|
||||
import { GuardedContent } from "@src/util/guards";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const skeletonCount = 12;
|
||||
const loadingFallbacks: ReactNode[] = new Array(skeletonCount)
|
||||
.fill(0)
|
||||
.map((_v: number, i: number) => <ProjectTileSkeleton key={i} />);
|
||||
|
||||
export default function ProjectView() {
|
||||
const { projectId } = useProjectContext();
|
||||
const { data, isLoading } = useProjectList(projectId);
|
||||
const projects = (data?.childProjects ?? []).filter((p) => p.id !== data?.project.id);
|
||||
return (
|
||||
<Flex h="100vh" w="100%">
|
||||
<Center w="100%">
|
||||
<Text>Project Management</Text>
|
||||
</Center>
|
||||
</Flex>
|
||||
<Box h="calc(100vh - 1.5rem)" w="100%" overflowY={isLoading ? "hidden" : "unset"}>
|
||||
<Flex wrap="wrap" m="1.5rem" gap="2rem">
|
||||
<Flex wrap="wrap" w="100%">
|
||||
<Text fontSize="18px" h="2rem" w="100%" fontWeight={800}>
|
||||
Project
|
||||
</Text>
|
||||
<Center w="100%">
|
||||
{isLoading && <ProjectTileSkeleton />}
|
||||
{!!data?.project && <ProjectTile project={data?.project} />}
|
||||
</Center>
|
||||
</Flex>
|
||||
|
||||
<GuardedContent guard={projects.length > 0}>
|
||||
<Flex wrap="wrap" w="100%">
|
||||
<Text fontSize="18px" h="2rem" w="100%" fontWeight={800}>
|
||||
Child Projects
|
||||
</Text>
|
||||
<Center gap="2rem" flexWrap="wrap" w="100%">
|
||||
{isLoading && loadingFallbacks}
|
||||
{projects.map((p) => (
|
||||
<ProjectTile key={p.id} project={p} />
|
||||
))}
|
||||
</Center>
|
||||
</Flex>
|
||||
</GuardedContent>
|
||||
|
||||
<GuardedContent guard={projects.length === 0}>
|
||||
<Flex h="40vh" w="100%">
|
||||
<Center w="100%" flexWrap="wrap">
|
||||
<Box bg="background.paper" p="1.75rem" borderRadius=".5rem">
|
||||
<Text w="100%" textAlign="center">
|
||||
No child projects found!
|
||||
</Text>
|
||||
<Text w="100%" textAlign="center">
|
||||
Create one by clicking here
|
||||
</Text>
|
||||
</Box>
|
||||
</Center>
|
||||
</Flex>
|
||||
</GuardedContent>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue