├── .eslintrc.json ├── .examples └── .env.form ├── .github └── workflows │ ├── deploy-dev.yml │ └── deploy-main.yml ├── .gitignore ├── .vscode └── settings.json ├── INSTALLATION.md ├── INSTALLATION_kr.md ├── LICENSE ├── README.md ├── README_kr.md ├── components ├── Comments.tsx ├── ErrorDiv.tsx ├── License.tsx ├── MainPageShowcase.tsx ├── ModalWrapper.tsx ├── Model.tsx ├── ModelInfo.tsx ├── ModelModal.tsx ├── Search.tsx ├── ThreeViewer.tsx ├── Thumbnails.tsx ├── Wrapper.tsx ├── header.tsx ├── login.tsx ├── svg.tsx └── userMenuModal.tsx ├── customTypes ├── api.ts ├── model.ts └── modelViewerJSX.ts ├── exec ├── 2gltf2.py ├── convert.sh ├── picGen.py └── thumbGen.sh ├── imgs ├── demo0_mainpage.jpg ├── demo1_3d_view0.jpg ├── demo1_QRCodeImg.jpg ├── demo1_ar_view0.jpg ├── demo2_3d_view0.jpg ├── demo2_QRCodeImg.jpg ├── demo2_ar_view0.jpg ├── demo_mainpage.jpg └── demo_viewerpage.jpg ├── libs ├── client │ ├── AccessDB.ts │ └── Util.ts └── server │ ├── Authorization.ts │ ├── ModelConverter.ts │ ├── ServerFileHandling.ts │ ├── prismaClient.ts │ └── s3client.ts ├── middleware.ts ├── module └── gltf-validator.d.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── 404.tsx ├── _app.tsx ├── api │ ├── auth │ │ └── [...nextauth].ts │ ├── comment │ │ └── index.ts │ ├── config │ │ └── index.ts │ ├── db │ │ └── initialize.ts │ ├── models │ │ ├── [id].ts │ │ └── index.ts │ └── users │ │ └── index.ts ├── dev │ ├── config.tsx │ ├── index.tsx │ └── models │ │ ├── delete.tsx │ │ ├── diff.tsx │ │ └── upload.tsx ├── index.tsx ├── models │ ├── [id] │ │ ├── index.tsx │ │ ├── three.tsx │ │ └── update.tsx │ ├── index.tsx │ └── upload.tsx └── users │ ├── [id] │ └── index.tsx │ └── index.tsx ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── MaruBuri-Regular.woff ├── closeBtn.png ├── comment.png ├── cube.png ├── list_gray.png ├── open_license.jpg ├── searchIcon.png ├── showcaseBanner-blackToWhite.png ├── upload1.png └── views.png ├── styles └── globals.css ├── supabase └── client.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.examples/.env.form: -------------------------------------------------------------------------------- 1 | DATABASE_URL='mysql://:@' 2 | 3 | NEXTAUTH_URL='' or 'http://localhost:3000' for local hosting' 4 | NEXTAUTH_SECRET= 5 | 6 | #Github OAuth cilent 7 | GITHUB_ID= 8 | GITHUB_SECRET= 9 | 10 | #Gmail OAuth client 11 | GOOGLE_ID= 12 | GOOGLE_SECRET= 13 | 14 | #s3 credencial 15 | S3_KEY_ID= 16 | S3_KEY= 17 | S3_BUCKET= 18 | S3_REGION= 19 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dev.yml: -------------------------------------------------------------------------------- 1 | name: remote ssh command for deploy 2 | on: 3 | push: 4 | branches: [develop] 5 | jobs: 6 | build: 7 | name: Build 8 | if: github.repository == 'parallelspaceInc/poly-web' 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: executing remote ssh commands using key 12 | uses: appleboy/ssh-action@master 13 | with: 14 | host: ${{ secrets.DEV_HOST }} 15 | username: ${{ secrets.DEV_USERNAME }} 16 | key: ${{ secrets.DEV_KEY }} 17 | port: ${{ secrets.DEV_PORT }} 18 | script: | 19 | export NVM_DIR=~/.nvm 20 | source ~/.nvm/nvm.sh 21 | ~/poly/start.sh 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy-main.yml: -------------------------------------------------------------------------------- 1 | name: remote ssh command for deploy 2 | on: 3 | push: 4 | branches: [main] 5 | jobs: 6 | build: 7 | name: Build 8 | if: github.repository == 'parallelspaceInc/poly-web' 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: executing remote ssh commands using key 12 | uses: appleboy/ssh-action@master 13 | with: 14 | host: ${{ secrets.HOST }} 15 | username: ${{ secrets.USERNAME }} 16 | key: ${{ secrets.KEY }} 17 | port: ${{ secrets.PORT }} 18 | script: | 19 | export NVM_DIR=~/.nvm 20 | source ~/.nvm/nvm.sh 21 | ~/poly/start_main.sh 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # env file 29 | .env 30 | 31 | # local env files 32 | .env* 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | 40 | .idea -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[prisma]": { 3 | "editor.defaultFormatter": "Prisma.prisma" 4 | }, 5 | "eslint.workingDirectories": [ 6 | { 7 | "mode": "auto" 8 | } 9 | ], 10 | "editor.formatOnSave": true, 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": true, 13 | "source.organizeImports": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Poly 2 | 3 | This is main repository for Poly, the model sharing platform. 4 | 5 | ## Dependancy 6 | 7 | Poly server is dependant on AWS S3 and MySQL. 8 | 9 | ## Build server 10 | 11 | To build you Poly Server, perform the following steps. 12 | 13 | - Clone repository and install node packages 14 | - Prepare S3Server for Poly server 15 | - Create OAuth ID from Google and Github 16 | - Update .env file 17 | - Run Poly Server 18 | 19 | ### Cloning repository 20 | 21 | To clone Poly repository, execute the commands below. 22 | 23 | ```bash 24 | $ git clone --depth=1 https://github.com/parallelspaceRE/poly-web 25 | $ cd poly-web 26 | $ npm install 27 | ``` 28 | 29 | ### Preparing S3Server 30 | 31 | Using AWS S3 may be charged under AWS policy. 32 | 33 | Create new S3 Bucket for Poly server. 34 | 35 | ### Creating OAuth ID 36 | 37 | Following steps are guides for server which domain name is **poly-web.com** 38 | 39 | If you don't have domain and want to run server for test, you can replace domain name to **http://localhost:3000**. 40 | 41 | #### Google 42 | 43 | Create OAuth 2.0 Client ID from [Google Cloud console](https://console.cloud.google.com/apis/credentials) 44 | 45 | - Click _Create Credentials_ -> OAuth client ID 46 | - Selecet _Web application_ 47 | - Fill **https://poly-web.com** for Authorized JavaScript origins 48 | - Fill **https://poly-web.com/api/auth/callback/google** for Authorized redirect URIs 49 | - Click _Create_ Button 50 | - Memo your client id and Secret 51 | 52 | #### Github 53 | 54 | Create OAuth ID from [Github Developer settings](https://github.com/settings/developers) 55 | 56 | - Click _New OAuth App_ 57 | - Fill App name 58 | - Fill **https://poly-web.com** for Homepage URL 59 | - Fill **https://poly-web.com/api/auth/callback/github** for Authorized callback URIs 60 | - Click _Register application_ 61 | - Click _Generate a new client secret_ then memo the secret and Client ID 62 | -------------------------------------------------------------------------------- /INSTALLATION_kr.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | Poly 프로젝트를 구동하는 방법에 대해 설명합니다. 4 | 5 | ## 필요 자원 6 | 7 | - AWS ec2 instance (최소사양) 8 | - RAM : 4GB 이상 9 | - SSD : 30GB 이상 10 | - CPU Architecture : x86 11 | - OS : Ubuntu 12 | - AWS S3 Bucket 13 | - 모델 데이터 저장소 14 | - 서버 운영자 소유의 Domain 주소 15 | - MySQL DB 16 | - Docker container나 Plenet Scale 등의 서비스 사용 가능 17 | 18 | ## 필요한 자원 준비하기 19 | 20 | ### AWS ec2 21 | 22 | 1. https://aws.amazon.com/ 에 접속하여 아이디를 생성후 로그인한다. 23 | 24 | 2. 좌측 상단의 서비스에서 ec2 항목을 찾아 접속한 뒤 인스턴스 시작 버튼을 누른다. 25 | 26 | 3. 앱 이미지로 Ubuntu 를 선택하고 인스턴스 유형으로 위에 기술한 최소사양 이상의 인스턴스를 선택한다. 27 | 28 | 4. 키페어를 새로 생성하거나 기존의 키페어를 사용한다. 새로운 키페어를 생성하는 경우에 파일을 보안이 갖추어진 폴더에 저장한다. 29 | 30 | 5. Allow https Traffic 에 체크한다. 31 | 32 | 6. 스토리지 구성시 30 GB 이상의 용량을 입력한다. 33 | 34 | 7. 인스턴스 시작을 눌러 인스턴스를 생성한다. 35 | 36 | 인스턴스에 접속하기 위해서는 명령 프롬프트(cmd, bash 등)에서 `$ ssh ubuntu@<인스턴스 IP> -i <키페어 경로>` 를 입력해 접속할 수 있다. 37 | 38 | 윈도우 운영체제에서는 C:\\Users\\\\.ssh 폴더를 탐색하여 키를 사용한다. 39 | 40 | 접속예제 : `$ ssh ubuntu@10.250.250.250` 41 | 42 | ### 탄력적 IP 사용 43 | 44 | 1. 인스턴스에 고정적인 IP를 부여하기 위해 탄력적 IP 항목에 접근한다. 45 | 46 | 2. 탄력적 IP 주소 할당버튼을 누른다. 47 | 48 | 3. 인스턴스가 생성된 지역을 누르고 할당을 누른다. 49 | 50 | 4. 새로 생성된 탄력적 주소를 체크표시 한 뒤 작업 버튼을 눌러 탄력적 IP 주소 연결을 선택한다. 51 | 52 | 5. 위에서 생성한 인스턴스를 선택하고 연결을 누른다. 53 | 54 | 6. 이후 DNS 서비스를 연결하기 전까지 탄력적 IP 를 이용해 인스턴스에 접속한다. 55 | 56 | ### AWS S3 bucket 57 | 58 | 1. 좌상단의 검색창을 사용해 S3 서비스에 접근한다. 59 | 60 | 2. 화면 우측의 버킷만들기 버튼을 누르고 버킷 이름을 설정한다. 61 | 62 | 3. AWS 리전을 주 사용자가 위치한 곳으로 설정한다. 63 | 64 | 4. 객체 소유권은 ACL 비활성화됨으로 설정한다. 65 | 66 | 5. 모든 퍼블릭 엑세스 차단 항목을 비활성화 하고 아래의 확인 문구에 체크표시한다. 67 | 68 | 6. 나머지 항목은 처음상태로 두고 버킷 만들기를 누른다. 69 | 70 | 7. 새로 생성된 버킷을 누른뒤 권한 항목으로 넘어간다. 71 | 72 | 8. 버킷정책에서 편집버튼을 누르고 다음 항목을 붙여넣는다. <버킷명> 부분을 만든 버킷 이름으로 대체한다. 73 | 74 | ``` 75 | { 76 | "Version": "2012-10-17", 77 | "Statement": [ 78 | { 79 | "Sid": "Statement1", 80 | "Effect": "Allow", 81 | "Principal": "*", 82 | "Action": [ 83 | "s3:PutObject", 84 | "s3:PutObjectAcl", 85 | "s3:GetObject", 86 | "s3:GetObjectAcl", 87 | "s3:DeleteObject" 88 | ], 89 | "Resource": "arn:aws:s3:::<버킷명>/*" 90 | } 91 | ] 92 | } 93 | ``` 94 | 95 | 9. CORS 항목에서 편집을 누르고 다음 항목을 추가한다. 96 | 97 | ``` 98 | [ 99 | { 100 | "AllowedHeaders": [ 101 | "*" 102 | ], 103 | "AllowedMethods": [ 104 | "GET", 105 | "POST", 106 | "DELETE" 107 | ], 108 | "AllowedOrigins": [ 109 | "localhost" 110 | ], 111 | "ExposeHeaders": [ 112 | "ETag", 113 | "x-amz-server-side-encryption", 114 | "x-amz-request-id", 115 | "x-amz-id-2" 116 | ] 117 | } 118 | ] 119 | ``` 120 | 121 | ### MySQL 122 | 123 | ### Domain Name Service 124 | 125 | ### https 126 | 127 | ### Social login API 128 | 129 | ### Poly project settings and build 130 | 131 | Poly 서버를 구축하려면 다음 단계를 수행하십시오. 132 | 133 | - 리포지토리 복제 및 노드 패키지 설치 134 | - Poly 서버용 S3Server 준비 135 | - Google 및 Github에서 OAuth ID 생성 136 | - .env 파일 업데이트 137 | - 폴리 서버 실행 138 | 139 | ### Cloning repository 140 | 141 | Poly 저장소를 복제하려면 아래 명령을 실행하십시오. 142 | 143 | 144 | ```bash 145 | $ git clone --depth=1 https://github.com/parallelspaceRE/poly-web 146 | $ cd poly-web 147 | $ npm install 148 | ``` 149 | 150 | 151 | ### Preparing S3 Server 152 | 153 | AWS S3를 사용하면 AWS 정책에 따라 요금이 부과될 수 있습니다. 154 | 155 | Poly 서버용 새 S3 버킷을 생성합니다. 156 | 157 | ### Creating OAuth ID 158 | 159 | 다음 단계는 도메인 이름이 poly-web.com 인 서버에 대한 안내입니다. 160 | 161 | 도메인이 없고 테스트를 위해 서버를 실행하려는 경우 도메인 이름을 http://localhost:3000 으로 바꿀 수 있습니다 . 162 | 163 | #### Google 164 | 165 | [Google Cloud console](https://console.cloud.google.com/apis/credentials) 에서 OAuth 2.0 클라이언트 ID 만들기 166 | 167 | - 자격 증명 만들기 -> OAuth 클라이언트 ID를 클릭 합니다. 168 | - 웹 애플리케이션 선택 169 | - 승인된 JavaScript 출처에 대해 https://poly-web.com 입력 170 | - 승인된 리디렉션 URI에 대해 https://poly-web.com/api/auth/callback/google 을 입력 합니다. 171 | - 만들기 버튼 클릭 172 | - 클라이언트 ID와 비밀 메모 173 | 174 | #### Github 175 | 176 | [Github Developer settings](https://github.com/settings/developers) 에서 OAuth ID 생성 177 | 178 | - 새 OAuth 앱 클릭 179 | - 앱 이름 채우기 180 | - 홈페이지 URL에 https://poly-web.com 입력 181 | - 승인된 콜백 URI에 대해 https://poly-web.com/api/auth/callback/github 채우기 182 | - 신청 등록 클릭 183 | - 새 클라이언트 암호 생성을 클릭 한 다음 암호와 클라이언트 ID를 메모합니다. 184 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 parallelspaceRE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Poly-Web Immersive Viewer Platform 2 | 3 | ## Development motive 4 | We want to develop a 3D viewer and AR viewer platform that can view Public Nuri Type 1 data or 3D models with CC0 license on PC web browsers and mobile web browsers. 5 |

6 | 7 | ## Service advantages 8 | - Even if you do not install the app in the mobile environment, you can view 3D content using the 3D viewer and AR viewer with a web browser. 9 | - Because it is sometimes inconvenient to visit the site to see real objects in tourism, education, shopping, etc., 3D viewing and AR virtual placement can compensate for these difficulties. 10 |

11 | 12 | ## Platform configuration 13 | 1. The main page consists of a list of model objects. 14 | 2. Select an object's thumbnail and enter it to view its 3D model and description. 15 | 3. On mobile, you can see a real-size object as if you were experiencing it with the AR view function.
16 | [Cultural Heritage Public Data 3D Content Demo Link](https://www.k-heritage.xyz/models) 17 |

18 | 19 | 20 |

21 |
22 |
23 | 24 | ## 3D and AR viewer mobile webpage example 25 | - Pottery equestrian figure type horn cup (Korean cultural heritage public data)
26 | [3D content demo link](https://www.k-heritage.xyz/models/8662da3f-333f-4646-a67f-a604c28b8d52) 27 | 28 |

29 | 30 | 31 | 32 | 33 |
34 |
35 | 36 | - Earthenware Ordinal Myeonggi (Korean Cultural Heritage Public Data)
37 | [3D Content Demo Link](https://www.k-heritage.xyz/models/9c2c3e89-53d6-453b-a5f3-fa164f2d5609) 38 | 39 |

40 | 41 | 42 | 43 | 44 |

45 |
46 | 47 | 48 | ## If you want to install this platform by yourself 49 | * You can open your own public 3D model platform using this project. 50 | * Please refer to INSTALLATION.md for installation. AWS and S3 Settings are required. 51 | -------------------------------------------------------------------------------- /README_kr.md: -------------------------------------------------------------------------------- 1 | # Poly-Web 실감 뷰어 플랫폼 2 | 3 | ## 개발 동기 4 | 공공누리 제1유형 데이터 혹은 CC0 라이선스인 3D 모델들을 PC 웹브라우저와 모바일 웹브라우저에서 볼 수 있는 3D 뷰어 및 AR 뷰어 플랫폼을 개발하고자 합니다. 5 |

6 | 7 | ## 서비스 장점 8 | - 모바일 환경에서 앱을 설치하지 않아도, 웹 브라우저로 3D 뷰어와 AR 뷰어를 이용해 3D 콘텐츠를 열람 할 수 있습니다. 9 | - 관광, 교육, 쇼핑 등에서 실물 오브젝트를 보기위해 현장을 방문하기 불편한 경우가 있기 때문에 3D 보기와 AR 가상 배치로 이러한 어려움을 보완할 수 있습니다. 10 |

11 | 12 | ## 플랫폼 구성 13 | 1. 메인 화면은 모델 오브젝트의 리스트로 구성되어 있습니다. 14 | 2. 오브젝트 썸네일을 선택해 들어가면 해당 3D 모델과 설명을 볼 수 있습니다. 15 | 3. 또한 모바일에서는 AR로 보기 기능으로 실물 크기의 물체를 직접 체험하듯 실감나게 볼 수 있습니다.
16 | [문화유산 공공데이터 3D 콘텐츠 데모 링크](https://www.k-heritage.xyz/models) 17 |

18 | 19 | 20 |

21 |
22 |
23 | 24 | ## 3D 및 AR 뷰어 모바일 웹페이지 예시 25 | - 도기 기마인물형 뿔잔 (우리나라 문화유산 공공데이터)
26 | [3D 콘텐츠 데모 링크](https://www.k-heritage.xyz/models/8662da3f-333f-4646-a67f-a604c28b8d52) 27 | 28 |

29 | 30 | 31 | 32 | 33 |
34 |
35 | 36 | - 도기 서수형 명기 (우리나라 문화유산 공공데이터)
37 | [3D 콘텐츠 데모 링크](https://www.k-heritage.xyz/models/9c2c3e89-53d6-453b-a5f3-fa164f2d5609) 38 | 39 |

40 | 41 | 42 | 43 | 44 |

45 |
46 | 47 | 48 | ## 별도의 플랫폼을 개설해 이용하고 싶은 경우 49 | * 본 프로젝트를 이용하여 공공 3D 모델 플랫폼을 자체 개설할 수 있습니다. 50 | * 설치는 INSTALLATION_kr.md를 참고 바랍니다. AWS S3 등의 셋팅이 필요합니다. 51 | -------------------------------------------------------------------------------- /components/Comments.tsx: -------------------------------------------------------------------------------- 1 | import { hasRight } from "@libs/server/Authorization"; 2 | import { Comment, User } from "@prisma/client"; 3 | import moment from "moment"; 4 | import Image from "next/image"; 5 | 6 | const Comments = ({ 7 | comments, 8 | handleDelete, 9 | user, 10 | ...props 11 | }: { 12 | comments?: Comment[]; 13 | handleDelete: any; 14 | user?: User | null; 15 | className?: any; 16 | }) => { 17 | return comments && comments.length !== 0 ? ( 18 |
19 | {comments.map((comment) => { 20 | return ( 21 | 27 | ); 28 | })} 29 |
30 | ) : null; 31 | }; 32 | 33 | export function Comment({ comment, handleDelete, user }: any) { 34 | return ( 35 |
39 |
40 | profile image 48 |
49 |
50 | {comment.commenter.name} 51 |
52 | {comment.text} 53 |
54 | 55 | {moment(comment.createdAt).fromNow()} 56 | 57 | {hasRight( 58 | { method: "delete", theme: "comment" }, 59 | user, 60 | null, 61 | comment 62 | ) ? ( 63 | handleDelete(comment.id)} 66 | > 67 | Delete 68 | 69 | ) : null} 70 |
71 |
72 |
73 |
74 | ); 75 | } 76 | 77 | export function NewComment({ session, handler, register, openLogin }: any) { 78 | if (!session || !session.data) { 79 | return ( 80 |
81 | 댓글을 달기 위해서는{" "} 82 | 86 | 로그인 87 | 88 | 이 필요합니다. 89 |
90 | ); 91 | } 92 | return ( 93 |
94 | profile image 102 |
103 | 112 |
113 |
114 | ); 115 | } 116 | 117 | export default Comments; 118 | -------------------------------------------------------------------------------- /components/ErrorDiv.tsx: -------------------------------------------------------------------------------- 1 | import { FieldError } from "react-hook-form"; 2 | 3 | const ErrorDiv = ({ error }: { error?: FieldError | undefined }) => ( 4 |
5 | {error?.type === "required" && error.message} 6 |
7 | ); 8 | export default ErrorDiv; 9 | -------------------------------------------------------------------------------- /components/License.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | const License = () => { 4 | return ( 5 |
6 |
7 | 라이선스:
8 | 공공누리 제1 유형 9 |
10 |
11 | nuri-1 license 12 |
13 |
14 | ); 15 | }; 16 | 17 | export default License; 18 | -------------------------------------------------------------------------------- /components/MainPageShowcase.tsx: -------------------------------------------------------------------------------- 1 | import { ResponseQuery } from "@api/config"; 2 | import { ModelInfo } from "@customTypes/model"; 3 | import dynamic from "next/dynamic"; 4 | import useSWR from "swr"; 5 | 6 | const Model = dynamic(() => import("@components/Model"), { ssr: false }); 7 | 8 | type Props = { 9 | modelInfo?: ModelInfo | null; 10 | }; 11 | 12 | const MainPageShowcase = ({ modelInfo }: Props) => { 13 | const { data: { texts } = {} } = useSWR( 14 | "/api/config?texts=true", 15 | (url) => fetch(url).then((res) => res.json()) 16 | ); 17 | 18 | return ( 19 |
23 |
24 | 25 | {texts?.mainPageGuideHead} 26 | 27 |
28 | {texts?.mainPageGuideBody1} 29 | {texts?.mainPageGuideBody2} 30 | {texts?.mainPageGuideBody3} 31 |
32 |
33 |
34 | {modelInfo ? ( 35 | 40 | ) : null} 41 |
42 |
43 | ); 44 | }; 45 | 46 | export default MainPageShowcase; 47 | -------------------------------------------------------------------------------- /components/ModalWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Wrapper from "./Wrapper"; 3 | 4 | function ModalWrapper({ 5 | children, 6 | closeCallback, 7 | pageMode = false, 8 | }: React.PropsWithChildren & { 9 | closeCallback: () => void; 10 | pageMode?: boolean; 11 | }) { 12 | //if clicked outside of modal, close modal 13 | const handleClick = (e: any) => { 14 | if (e.target.classList.contains("backlayer")) { 15 | closeCallback(); 16 | } 17 | }; 18 | return pageMode ? ( 19 | {children} 20 | ) : ( 21 |
25 |
29 | {children} 30 |
31 |
32 | ); 33 | } 34 | 35 | export default ModalWrapper; 36 | -------------------------------------------------------------------------------- /components/Model.tsx: -------------------------------------------------------------------------------- 1 | import { ModelInfo } from "@customTypes/model"; 2 | import "@google/model-viewer"; 3 | import { round } from "lodash"; 4 | import { increaseView } from "pages/models/[id]"; 5 | import { useEffect, useState } from "react"; 6 | 7 | const Model = ({ 8 | info, 9 | hideThumbnailUntilLoaded = false, 10 | appendLog, 11 | increaseViewCount = true, 12 | }: { 13 | info: ModelInfo; 14 | hideThumbnailUntilLoaded?: boolean; 15 | appendLog?: (log: string) => void; 16 | increaseViewCount?: boolean; 17 | }) => { 18 | const [progress, setProgress] = useState(0); 19 | const [viewerId, setViewerId] = useState(""); 20 | const [isVisible, setIsVisible] = useState( 21 | !hideThumbnailUntilLoaded 22 | ); 23 | useEffect(() => { 24 | setViewerId(`model-viewer-${Math.random()}`); 25 | }, []); 26 | useEffect(() => { 27 | const begin = Date.now(); 28 | const progressCallback = (xhr: any) => { 29 | setProgress(xhr.detail.totalProgress); 30 | if (xhr.detail.totalProgress === 1) { 31 | setTimeout(() => { 32 | setIsVisible(true); 33 | }, 500); 34 | appendLog?.( 35 | ` : Loading spent ${(Date.now() - begin) / 1000} sec.` 36 | ); 37 | if (increaseViewCount) { 38 | increaseView(info.id); 39 | } 40 | } 41 | }; 42 | const modelComponent = document.getElementById(viewerId); 43 | modelComponent?.addEventListener("progress", progressCallback); 44 | return () => { 45 | modelComponent?.removeEventListener("progress", progressCallback); 46 | }; 47 | }, [info.id, appendLog, viewerId, increaseViewCount]); 48 | const parsed = { 49 | src: `${info.modelSrc}`, 50 | "ios-src": info.usdzSrc ?? "", 51 | poster: info.thumbnailSrc, 52 | alt: info.name, 53 | "shadow-intensity": "1", 54 | "camera-controls": "", 55 | "auto-rotate": "", 56 | ar: "", 57 | "ar-modes": "scene-viewer", 58 | style: { 59 | width: "100%", 60 | height: "100%", 61 | }, 62 | // exposure: "1", 63 | // "environment-image": "neutral", 64 | // "skybox-image": 65 | // "https://modelviewer.dev/assets/whipple_creek_regional_park_04_1k.hdr", 66 | }; 67 | 68 | return ( 69 |
70 |
76 | 77 |
78 |
79 |
80 | 81 |
82 | ); 83 | }; 84 | 85 | const LoadingBar = ({ progress }: any) => { 86 | const [isVisible, setIsVisible] = useState(true); 87 | if (progress === 1) { 88 | setTimeout(() => { 89 | setIsVisible(false); 90 | }, 500); 91 | } 92 | return ( 93 |
100 |
Loading Model
101 |
105 |
109 | {round(progress * 100, 1)}% 110 |
111 |
112 |
113 | ); 114 | }; 115 | 116 | export default Model; 117 | -------------------------------------------------------------------------------- /components/ModelInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useModelInfo } from "@libs/client/AccessDB"; 2 | import { AddUnit } from "@libs/client/Util"; 3 | 4 | const ModelInfo = ({ modelId }: { modelId: string }) => { 5 | const model = useModelInfo(modelId); 6 | return ( 7 |
8 |
9 |
Vertices:
10 |
11 | {AddUnit(model.data?.modelVertex) ?? "unknown"} 12 |
13 |
14 |
15 |
Triangles:
16 |
17 | {AddUnit(model.data?.modelTriangle) ?? "unknown"} 18 |
19 |
20 |
21 |
Model Size:
22 |
23 | {AddUnit(model.data?.modelSize) + "B" ?? "unknown"} 24 |
25 |
26 |
27 | ); 28 | }; 29 | 30 | export default ModelInfo; 31 | -------------------------------------------------------------------------------- /components/ModelModal.tsx: -------------------------------------------------------------------------------- 1 | import Comments, { NewComment } from "@components/Comments"; 2 | import License from "@components/License"; 3 | import ModalWrapper from "@components/ModalWrapper"; 4 | import ModelInfo from "@components/ModelInfo"; 5 | import { useModelInfo, useSiteConfig, useUser } from "@libs/client/AccessDB"; 6 | import { hasRight } from "@libs/server/Authorization"; 7 | import { Role } from "@prisma/client"; 8 | import { useSession } from "next-auth/react"; 9 | import dynamic from "next/dynamic"; 10 | import { NextRouter, useRouter } from "next/router"; 11 | import { Dispatch, SetStateAction, useState } from "react"; 12 | import { FieldValues, useForm } from "react-hook-form"; 13 | import { useSWRConfig } from "swr"; 14 | 15 | const Model = dynamic(() => import("@components/Model"), { ssr: false }); 16 | const SHOW_CATEGORY = false; 17 | 18 | interface ModelElemet extends Element { 19 | showPoster: () => void; 20 | dismissPoster: () => void; 21 | } 22 | 23 | const ModelModal = ({ 24 | closeCallback, 25 | modelId, 26 | pageMode = false, 27 | }: { 28 | closeCallback: () => void; 29 | modelId: string; 30 | pageMode?: boolean; 31 | }) => { 32 | const router = useRouter(); 33 | const modelInfo = useModelInfo(modelId); 34 | const user = useUser(); 35 | const [isLogShown, setIsLogShown] = useState(false); 36 | const [logs, setLogs] = useState([]); 37 | const session = useSession(); 38 | const { register, formState, reset: resetComment, handleSubmit } = useForm(); 39 | const { mutate: componentMutate } = useSWRConfig(); 40 | const { data: { config } = {} } = useSiteConfig(); 41 | 42 | const onValid = async (form: FieldValues) => { 43 | if (formState.isSubmitting) return; 44 | const res = await fetch(`/api/comment?modelId=${modelId}`, { 45 | method: "POST", 46 | body: JSON.stringify(form), 47 | }); 48 | if (!res.ok) { 49 | alert("코멘트 업로드에 실패하였습니다."); 50 | } 51 | componentMutate(`/api/models?id=${modelId}`); 52 | resetComment({ text: "" }); 53 | }; 54 | const appendLog = (message: string) => setLogs(() => logs.concat(message)); 55 | 56 | if (modelInfo.error || user.error) { 57 | router.push("/"); 58 | return null; 59 | } 60 | if (!modelInfo.data) { 61 | return null; 62 | } 63 | 64 | return ( 65 | 66 |
67 |
68 | {isLogShown ? ( 69 |
70 | {logs.map((log, index) => ( 71 | 72 | {log} 73 | 74 | ))} 75 |
76 | ) : null} 77 | {!modelInfo.loading ? ( 78 | 79 | ) : ( 80 | "Loading..." 81 | )} 82 | {([Role.ADMIN, Role.DEVELOPER] as any).includes( 83 | user?.data?.role ?? Role.UNAUTHENTICATED 84 | ) ? ( 85 | <> 86 | 94 | 102 | 103 | ) : null} 104 |
105 |
110 | {hasRight( 111 | { method: "read", theme: "model" }, 112 | user.data, 113 | modelInfo.data 114 | ) ? ( 115 | 123 | ) : null} 124 | {hasRight( 125 | { method: "update", theme: "model" }, 126 | user.data, 127 | modelInfo.data 128 | ) ? ( 129 | 135 | ) : null} 136 | {hasRight( 137 | { method: "update", theme: "model" }, 138 | user.data, 139 | modelInfo.data 140 | ) ? ( 141 | 149 | ) : null} 150 | {hasRight( 151 | { method: "delete", theme: "model" }, 152 | user.data, 153 | modelInfo.data 154 | ) ? ( 155 | 161 | ) : null} 162 |
163 |
164 | 165 | {!modelInfo.loading ? modelInfo.data.name : ""} 166 | 167 | 168 | {SHOW_CATEGORY ? ( 169 | 170 | {!modelInfo.loading ? `Category > ${modelInfo.data.category}` : ""} 171 | 172 | ) : null} 173 | 174 | {!modelInfo.loading ? modelInfo.data.description : ""} 175 | 176 | {config?.isLicenseVisible === "true" ? : null} 177 | {!modelInfo.loading ? ( 178 |
179 |
180 | {`댓글 (${modelInfo.data.Comment?.length})`} 181 |
182 | { 187 | document.getElementById("login-button")?.click(); 188 | }} 189 | > 190 | 193 | handleDelete(commentId, () => { 194 | componentMutate(`/api/models?id=${modelId}`); 195 | }) 196 | } 197 | user={user.data} 198 | > 199 |
200 | ) : null} 201 |
202 | ); 203 | }; 204 | 205 | const handleDeleteRequest = (id: string, router: NextRouter) => { 206 | if (window.confirm("정말로 삭제하시겠습니까?")) { 207 | fetch(`/api/models/${id}`, { 208 | method: "DELETE", 209 | }) 210 | .then((res) => { 211 | if (!res.ok) { 212 | throw res.json(); 213 | } 214 | router.push(`/models`); 215 | }) 216 | .catch((error) => { 217 | alert(`error : ${error.message}`); 218 | }); 219 | } else { 220 | // do nothing 221 | } 222 | }; 223 | 224 | const onDownloadClick = async ( 225 | modelId: string, 226 | setLogs: Dispatch>, 227 | zipName?: string 228 | ) => { 229 | const startAt = performance.now(); 230 | const res = await fetch(`/api/models/${modelId}`) 231 | .then((res) => res.blob()) 232 | .then((blob) => { 233 | const url = URL.createObjectURL(blob); 234 | const link = document.createElement("a"); 235 | link.href = url; 236 | // link.download = zipName + ".zip" ?? "model.zip"; 237 | link.download = zipName + ".glb"; 238 | link.click(); 239 | link.remove(); 240 | }); 241 | const spentTime = Math.floor(performance.now() - startAt); 242 | setLogs((logs) => 243 | logs.concat(` : download spent ${spentTime / 1000} sec.`) 244 | ); 245 | }; 246 | 247 | async function handleUsdzMod(id: string) { 248 | const tempInput = document.createElement("input"); 249 | tempInput.type = "file"; 250 | const form = new FormData(); 251 | const controller = new AbortController(); 252 | tempInput.onchange = async (e: any) => { 253 | const timeoutId = setTimeout(() => controller.abort(), 15000); 254 | form.append("file", tempInput.files?.[0] ?? ""); 255 | const res = await fetch(`/api/models?modUsdz=true&modelId=${id}`, { 256 | body: form, 257 | method: "POST", 258 | signal: controller.signal, 259 | }) 260 | .then((res) => { 261 | if (!res.ok) { 262 | throw Error(); 263 | } else { 264 | alert("성공적으로 수정되었습니다."); 265 | } 266 | }) 267 | .catch((e) => alert(`usdz 수정에 실패하였습니다.\n${e}`)); 268 | tempInput.remove(); 269 | }; 270 | tempInput.click(); 271 | } 272 | 273 | async function handleDelete(commentId: string, refresh: () => void) { 274 | const res = await fetch(`/api/comment?commentId=${commentId}`, { 275 | method: "DELETE", 276 | }).then((res) => res.json()); 277 | if (!res.ok) { 278 | const message = res.message ? "\n" + res.message : ""; 279 | alert(`코멘트 삭제에 실패했습니다.` + message); 280 | } 281 | { 282 | refresh(); 283 | } 284 | } 285 | export const increaseView = (modelId: string) => { 286 | fetch(`/api/models/${modelId}?view=true`, { 287 | method: "POST", 288 | }); 289 | }; 290 | 291 | export default ModelModal; 292 | -------------------------------------------------------------------------------- /components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { Categories } from "@libs/client/Util"; 2 | import Image from "next/image"; 3 | import { ModelInfos } from "pages/models"; 4 | import React, { 5 | Dispatch, 6 | SetStateAction, 7 | useCallback, 8 | useEffect, 9 | useState 10 | } from "react"; 11 | interface Props { 12 | setModels: Dispatch>; 13 | } 14 | type Query = { 15 | sort: string; 16 | orderBy: string; 17 | filterByName?: string; 18 | }; 19 | type SortType = "Last Added" | "Size" | "Alphabetic" | "Popularity"; 20 | type SortTypes = SortType[]; 21 | interface IsDescOfSortType { 22 | lastAdded: boolean; 23 | size: boolean; 24 | alphabetic: boolean; 25 | popularity: boolean; 26 | [prop: string]: boolean; 27 | } 28 | 29 | interface OrderByKey { 30 | lastAdded: string; 31 | size: string; 32 | alphabetic: string; 33 | [prop: string]: string; 34 | } 35 | 36 | const orderByKey: OrderByKey = { 37 | lastAdded: "createdAt", 38 | size: "modelSize", 39 | alphabetic: "name", 40 | popularity: "viewed", 41 | }; 42 | 43 | const categories: string[] = ["All categories", ...Categories]; 44 | 45 | function SearchBar({ setModels }: Props) { 46 | const defaultIsDesc: IsDescOfSortType = { 47 | lastAdded: true, 48 | size: true, 49 | alphabetic: false, 50 | popularity: true, 51 | }; 52 | 53 | const sortTypes: SortTypes = [ 54 | "Popularity", 55 | "Last Added", 56 | "Size", 57 | "Alphabetic", 58 | ]; 59 | 60 | const dictionary = { 61 | "Popularity": "인기순", 62 | "Last Added": "등록순", 63 | "Size": "용량순", 64 | "Alphabetic": "이름순", 65 | } 66 | 67 | const [currentSortType, setCurrentSortType] = useState( 68 | sortTypes[0] 69 | ); 70 | const [isDesc, setIsDesc] = useState({ ...defaultIsDesc }); 71 | const [filterByName, setFilterByName] = useState(""); 72 | const [inputValue, setInputValue] = useState(""); 73 | const [currentCategory, setCurrentCategory] = useState(categories[0]); 74 | 75 | const [isClickSort, setIsClickSort] = useState(false); 76 | const [isClickCategory, setIsClickCategory] = useState(false); 77 | 78 | const closeSortingModel = () => { 79 | setIsClickSort(false); 80 | }; 81 | const closeCategoryModel = () => { 82 | setIsClickCategory(false); 83 | }; 84 | 85 | useEffect(() => { 86 | const handleClickOutSide = (e: MouseEvent) => { 87 | if (e.target instanceof Element) { 88 | const isModalClicked = !!e.target.closest("#sorting"); 89 | if (isModalClicked) { 90 | closeCategoryModel(); 91 | return; 92 | } 93 | const isCategoryModalClicked = !!e.target.closest("#category"); 94 | if (isCategoryModalClicked) { 95 | closeSortingModel(); 96 | return; 97 | } 98 | } 99 | closeCategoryModel(); 100 | closeSortingModel(); 101 | }; 102 | 103 | document.addEventListener("mousedown", handleClickOutSide); 104 | 105 | return () => { 106 | document.removeEventListener("mousedown", handleClickOutSide); 107 | }; 108 | }, []); 109 | 110 | const getModelsCallBack = useCallback(async () => { 111 | const isDescOfSort = isDesc[getSortTypeKey(currentSortType)]; 112 | const sort = orderByKey[`${getSortTypeKey(currentSortType)}`]; 113 | const orderBy = isDescOfSort ? "desc" : "asc"; 114 | const filter = 115 | filterByName.replace(/\s/gi, "") !== "" ? filterByName.trim() : undefined; 116 | const query: Query = { 117 | sort, 118 | orderBy, 119 | }; 120 | if (filter) { 121 | Object.assign(query, { filterByName: filter }); 122 | } 123 | 124 | if (currentCategory !== categories[0]) { 125 | Object.assign(query, { category: currentCategory }); 126 | } 127 | 128 | const queryString = new URLSearchParams(query).toString(); 129 | 130 | const { data, error } = await fetch(`/api/models?${queryString}`, { 131 | method: "GET", 132 | }).then((res) => res.json()); 133 | const loading = !data && !error; 134 | setModels({ 135 | loading, 136 | data, 137 | error, 138 | }); 139 | }, [currentSortType, filterByName, isDesc, setModels, currentCategory]); 140 | 141 | useEffect(() => { 142 | getModelsCallBack(); 143 | }, [getModelsCallBack]); 144 | 145 | const sortingModel = async (type: SortType) => { 146 | const sortType = getSortTypeKey(type); 147 | const sortTypeIsDesc = isDesc[sortType]; 148 | if (type === currentSortType) { 149 | isDesc[sortType] = !sortTypeIsDesc; 150 | setIsDesc({ ...isDesc }); 151 | } else { 152 | setCurrentSortType(type); 153 | } 154 | closeSortingModel(); 155 | }; 156 | 157 | const getSortTypeKey = (type: SortType): string => { 158 | const sortType = 159 | type.charAt(0).toLowerCase() + type.slice(1).replace(" ", ""); 160 | return sortType; 161 | }; 162 | 163 | const searchModel = async (e?: React.KeyboardEvent) => { 164 | if ((e && e?.key !== "Enter") || filterByName === inputValue) { 165 | return; 166 | } 167 | 168 | setFilterByName(inputValue); 169 | }; 170 | 171 | const onChangeFilter = (e: React.ChangeEvent) => { 172 | setInputValue(e.target.value); 173 | }; 174 | 175 | const arrowIcon = ( 176 | sortType: SortType 177 | ): React.ReactElement => { 178 | const wahtArrow = (isDesc: boolean) => { 179 | if (isDesc) { 180 | return ; 181 | } 182 | return ; 183 | }; 184 | 185 | if (sortType === currentSortType) { 186 | return wahtArrow(!isDesc[getSortTypeKey(sortType)]); 187 | } 188 | return wahtArrow(isDesc[getSortTypeKey(sortType)]); 189 | }; 190 | 191 | const onChangeCategory = (category: string) => { 192 | setCurrentCategory(category); 193 | setIsClickCategory(false); 194 | }; 195 | 196 | return ( 197 |
198 |
199 |
200 |
201 | searchModel(e)} 207 | /> 208 |
209 | 210 |
searchModel()} 213 | > 214 | Search model 220 |
221 |
222 |
226 |
{ 229 | setIsClickSort(!isClickSort); 230 | }} 231 | > 232 |
233 | {dictionary[currentSortType]} 234 | {isDesc[getSortTypeKey(currentSortType)] ? ( 235 | 236 | ) : ( 237 | 238 | )} 239 |
240 |
241 |
    245 | {sortTypes.map((list) => { 246 | return ( 247 |
  • sortingModel(list)} 251 | > 252 |

    253 | {dictionary[list]}{arrowIcon(list)} 254 |

    255 |
  • 256 | ); 257 | })} 258 |
259 |
260 |
261 | {/*
262 | 263 |
267 |
setIsClickCategory(!isClickCategory)} 270 | > 271 |

{currentCategory}

272 |

273 | list 279 |

280 |
281 |
282 |
    287 | {categories.map((list) => { 288 | return ( 289 |
  • onChangeCategory(list)} 293 | > 294 | {list} 295 |
  • 296 | ); 297 | })} 298 |
299 |
300 |
301 |
*/} 302 |
303 | ); 304 | } 305 | 306 | export default SearchBar; 307 | -------------------------------------------------------------------------------- /components/ThreeViewer.tsx: -------------------------------------------------------------------------------- 1 | import { OrbitControls, Stats } from "@react-three/drei"; 2 | import { useLoader } from "@react-three/fiber"; 3 | import { ARCanvas } from "@react-three/xr"; 4 | import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"; 5 | 6 | const ThreeViewer = ({ url }: { url: string; [key: string]: any }) => { 7 | const gltf = useLoader(GLTFLoader, url); 8 | return ( 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default ThreeViewer; 22 | -------------------------------------------------------------------------------- /components/Thumbnails.tsx: -------------------------------------------------------------------------------- 1 | import { ModelInfo } from "@customTypes/model"; 2 | import { AddUnit } from "@libs/client/Util"; 3 | import Image from "next/image"; 4 | import { useRouter } from "next/router"; 5 | import { increaseView } from "pages/models/[id]"; 6 | import { 7 | Dispatch, 8 | MouseEvent, 9 | SetStateAction, 10 | useEffect, 11 | useState, 12 | } from "react"; 13 | import ModelModal from "./ModelModal"; 14 | 15 | type pageMode = "default" | "select"; 16 | 17 | function Thumbnails({ 18 | loading, 19 | modelInfos, 20 | devMode = false, 21 | }: { 22 | loading: boolean; 23 | modelInfos?: ModelInfo[]; 24 | devMode?: boolean; 25 | }) { 26 | const router = useRouter(); 27 | const [mode, setMode] = useState("default"); 28 | const [selectedModels, setSelectedModels] = useState([]); 29 | const modalId = router.asPath.match(/\/models\/(.+)/)?.[1]; 30 | useEffect(() => { 31 | if (router.asPath.match(/\/models\/(.+)/)?.[1]) { 32 | document.body.classList.add("overflow-hidden"); 33 | } else { 34 | document.body.classList.remove("overflow-hidden"); 35 | } 36 | return () => { 37 | document.body.classList.remove("overflow-hidden"); 38 | }; 39 | }, [router]); 40 | 41 | return ( 42 | <> 43 |
44 | {!loading && modelInfos ? ( 45 | modelInfos.map((info, i) => 46 | !info.blinded || devMode ? ( 47 | 132 | ) : null 133 | ) 134 | ) : ( 135 | Loading... 136 | )} 137 | {devMode ? ( 138 | <> 139 |
140 | 146 |
147 |
148 |
{ 151 | await handleMultipleDeleteRequest(selectedModels); 152 | router.reload(); 153 | }} 154 | > 155 | 삭제 156 |
157 |
{ 160 | await handleMultipleBlindRequest(selectedModels, true); 161 | router.reload(); 162 | }} 163 | > 164 | 블라인드 165 |
166 |
{ 168 | await handleMultipleBlindRequest(selectedModels, false); 169 | router.reload(); 170 | }} 171 | > 172 | 블라인드 해제 173 |
174 |
175 | 176 | ) : null} 177 |
178 | {modalId ? ( 179 | { 182 | router.push(router.basePath, router.basePath, { scroll: false }); 183 | document.body.classList.remove("overflow-hidden"); 184 | }} 185 | /> 186 | ) : null} 187 | 188 | ); 189 | } 190 | 191 | export default Thumbnails; 192 | 193 | const IconWithCounter = ({ 194 | current, 195 | imageAttributes, 196 | increaseIfDev, 197 | onClick, 198 | increasingCallback, 199 | }: { 200 | current: number; 201 | increaseIfDev?: boolean; 202 | imageAttributes: ImageAttributes; 203 | onClick?: (event: MouseEvent) => void; 204 | increasingCallback?: () => void; 205 | }) => { 206 | const [counter, setCounter] = useState(0); 207 | const { alt, ...attributesWithoutAlt } = imageAttributes; 208 | return ( 209 |
{ 212 | if (increaseIfDev) { 213 | increasingCallback?.(); 214 | setCounter((prev) => prev + 1); 215 | } 216 | onClick?.(e); 217 | }} 218 | > 219 |
220 | {alt} 221 |
222 | 223 | {AddUnit(current + counter) ?? 0} 224 | 225 |
226 | ); 227 | }; 228 | 229 | const ModeChangeButton = ({ 230 | curMode, 231 | buttonMode, 232 | setMode, 233 | router, 234 | }: { 235 | curMode: pageMode; 236 | buttonMode: pageMode; 237 | setMode: Dispatch>; 238 | router: any; 239 | }) => ( 240 |
{ 243 | curMode !== buttonMode 244 | ? setMode(buttonMode) 245 | : (() => { 246 | setMode("default"); 247 | router.reload(); 248 | })(); 249 | }} 250 | > 251 | {curMode !== buttonMode 252 | ? buttonMode + "모드 켜기" 253 | : buttonMode + "모드 끄기"} 254 |
255 | ); 256 | 257 | const handleHideRequest = async (selectedModel: string, blind: boolean) => { 258 | const form = new FormData(); 259 | form.append("blind", String(blind)); 260 | form.append("model", selectedModel); 261 | const res = await fetch(`/api/models?devMode=true`, { 262 | body: form, 263 | method: "PATCH", 264 | }); 265 | }; 266 | 267 | const handleDeleteRequest = async (selectedModel: string) => { 268 | const res = await fetch(`/api/models/${selectedModel}`, { 269 | method: "DELETE", 270 | }); 271 | }; 272 | 273 | const handleMultipleBlindRequest = async ( 274 | modelIds: string[], 275 | blindValue: boolean 276 | ) => { 277 | const formBody = new FormData(); 278 | const targetModels = modelIds.forEach((id) => 279 | formBody.append("modelList", id) 280 | ); 281 | await fetch(`/api/models?devMode=true&massive=true&blind=${blindValue}`, { 282 | method: "PATCH", 283 | body: formBody, 284 | }).then((res) => res.json()); 285 | }; 286 | 287 | const handleMultipleDeleteRequest = async (modelIds: string[]) => { 288 | const formBody = new FormData(); 289 | const targetModels = modelIds.forEach((id) => 290 | formBody.append("modelList", id) 291 | ); 292 | await fetch("/api/models?massive=true", { 293 | method: "DELETE", 294 | body: formBody, 295 | }).then((res) => res.json()); 296 | }; 297 | 298 | interface ImageAttributes { 299 | src: string; 300 | alt: string; 301 | width?: number; 302 | height?: number; 303 | layout: "responsive" | "fill" | "fixed"; 304 | } 305 | -------------------------------------------------------------------------------- /components/Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function Wrapper({ children }: React.PropsWithChildren) { 4 | return ( 5 |
6 |
7 | {children} 8 |
9 |
10 | ); 11 | } 12 | 13 | export default Wrapper; 14 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | import Login from "@components/login"; 2 | import UserMenuModal from "@components/userMenuModal"; 3 | import { useUploadable } from "@libs/client/AccessDB"; 4 | import { signOut, useSession } from "next-auth/react"; 5 | import Image from "next/image"; 6 | import { useRouter } from "next/router"; 7 | import { useState } from "react"; 8 | import useSWR from "swr"; 9 | 10 | export default function Header() { 11 | const [isOpenLoginCP, setIsOpenLoginCp] = useState(false); 12 | const [isClickUserName, setIsClickUserName] = useState(false); 13 | const { data: session, status } = useSession(); 14 | const { data: { texts } = {} } = useSWR("/api/config?texts=true", (url) => 15 | fetch(url).then((res) => res.json()) 16 | ); 17 | const router = useRouter(); 18 | const isUploadable = useUploadable(); 19 | 20 | const closeLoginBox = (): void => { 21 | setIsOpenLoginCp(false); 22 | document.body.classList.remove("overflow-hidden"); 23 | }; 24 | 25 | const clickUserNameBox = () => { 26 | setIsClickUserName(!isClickUserName); 27 | }; 28 | 29 | const uploadRounter = () => { 30 | router.push("/models/upload"); 31 | }; 32 | 33 | return ( 34 |
41 |
46 |
router.push("/")} 51 | > 52 | {texts?.title} 53 |
54 |
55 | {isUploadable ? ( 56 | 70 | ) : null} 71 | {status === "authenticated" ? ( 72 |
78 |
79 |

{session?.user?.name}

80 |
81 | 89 | 93 | 94 |
95 | ) : ( 96 |
{ 100 | setIsOpenLoginCp(!isOpenLoginCP); 101 | document.body.classList.add("overflow-hidden"); 102 | }} 103 | > 104 | LOGIN 105 |
106 | )} 107 |
108 |
109 | {isOpenLoginCP && } 110 | {isClickUserName && } 111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /components/login.tsx: -------------------------------------------------------------------------------- 1 | import { signIn } from "next-auth/react"; 2 | 3 | type props = { 4 | closeLoginBox: () => void; 5 | }; 6 | 7 | export default function Login({ closeLoginBox }: props) { 8 | return ( 9 |
{ 12 | if (e.target instanceof Element) { 13 | const isModalClicked = !!e.target.closest("#modal"); 14 | if (isModalClicked) { 15 | return; 16 | } 17 | } 18 | closeLoginBox(); 19 | }} 20 | > 21 | 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /components/svg.tsx: -------------------------------------------------------------------------------- 1 | type SVGElement = (attributes: { 2 | className?: string; 3 | height?: number; 4 | width?: number; 5 | color?: string; 6 | }) => any; 7 | 8 | export const Eye: SVGElement = (attributes) => ( 9 | 16 | 17 | 21 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /components/userMenuModal.tsx: -------------------------------------------------------------------------------- 1 | import { Role } from "@prisma/client"; 2 | import { useSession } from "next-auth/react"; 3 | import Link from "next/link"; 4 | import { useRouter } from "next/router"; 5 | import React from "react"; 6 | 7 | type props = { 8 | logOut: () => void; 9 | }; 10 | 11 | const UserMenuModal = React.forwardRef((props, ref) => { 12 | const router = useRouter(); 13 | const { data: session } = useSession(); 14 | return ( 15 |
21 |
    22 |
  • { 24 | router.push("/users"); 25 | }} 26 | className={"px-16 py-4 text-center"} 27 | > 28 | 마이페이지 29 |
  • 30 |
  • 31 | 로그아웃 32 |
  • 33 | {session?.role === Role.ADMIN || session?.role === Role.DEVELOPER ? ( 34 | 35 |
  • To DevPage
  • 36 | 37 | ) : null} 38 |
39 |
40 | ); 41 | }); 42 | 43 | UserMenuModal.displayName = "UserMenuModal"; 44 | 45 | export default UserMenuModal; 46 | -------------------------------------------------------------------------------- /customTypes/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Promise.allSettled return Promise type. 3 | */ 4 | export type PromiseAllSettledResult = { 5 | status: "fulfilled" | "rejected"; 6 | /** 7 | * Return value from promise when it's fulfilled. 8 | */ 9 | value?: V; 10 | /** 11 | * Returned message from promise when it's rejected. 12 | */ 13 | reason?: E; 14 | }; 15 | -------------------------------------------------------------------------------- /customTypes/model.ts: -------------------------------------------------------------------------------- 1 | import { Model, ModelCategory } from "@prisma/client"; 2 | export interface ModelInfo extends Model { 3 | modelSrc: string; 4 | thumbnailSrc: string; 5 | usdzSrc: string; 6 | [key: string]: any; 7 | } 8 | 9 | export type UploadForm = { 10 | name: string; 11 | description?: string | null; 12 | category: ModelCategory; 13 | tag?: string; 14 | }; 15 | 16 | export declare module ValidatorInfo { 17 | export interface Message { 18 | code: string; 19 | message: string; 20 | severity: number; 21 | pointer: string; 22 | } 23 | 24 | export interface Issues { 25 | numErrors: number; 26 | numWarnings: number; 27 | numInfos: number; 28 | numHints: number; 29 | messages: Message[]; 30 | truncated: boolean; 31 | } 32 | 33 | export interface Resource { 34 | pointer: string; 35 | mimeType: string; 36 | storage: string; 37 | byteLength: number; 38 | } 39 | 40 | export interface Info { 41 | version: string; 42 | generator: string; 43 | resources: Resource[]; 44 | animationCount: number; 45 | materialCount: number; 46 | hasMorphTargets: boolean; 47 | hasSkins: boolean; 48 | hasTextures: boolean; 49 | hasDefaultScene: boolean; 50 | drawCallCount: number; 51 | totalVertexCount: number; 52 | totalTriangleCount: number; 53 | maxUVs: number; 54 | maxInfluences: number; 55 | maxAttributes: number; 56 | } 57 | 58 | export interface RootObject { 59 | mimeType: string; 60 | validatorVersion: string; 61 | validatedAt: Date; 62 | issues: Issues; 63 | info: Info; 64 | } 65 | } 66 | 67 | type NotRequired = { 68 | [P in keyof T]+?: T[P]; 69 | }; 70 | 71 | export type OptionalModel = NotRequired; 72 | 73 | export type Comment = { 74 | id: number; 75 | profileUrl: string; 76 | userId: string; 77 | createdAt: string; 78 | updatedAt: string; 79 | text: string; 80 | userName: string; 81 | }; 82 | 83 | const siteTextIds = [ 84 | "title", 85 | "mainPageGuideHead", 86 | "mainPageGuideBody1", 87 | "mainPageGuideBody2", 88 | "mainPageGuideBody3", 89 | ] as const; 90 | 91 | const siteConfigIds = [ 92 | "showCaseModelId", 93 | "isShowcaseVisible", 94 | "isLicenseVisible", 95 | "isUserUploadable", 96 | ] as const; 97 | 98 | export type SiteTextProps = { 99 | [props in typeof siteTextIds[number]]: string; 100 | }; 101 | 102 | export type SiteConfigProps = { 103 | [props in typeof siteConfigIds[number]]: string; 104 | }; 105 | 106 | export const defaultSiteConfig: { 107 | [key in typeof siteConfigIds[number]]: string | null; 108 | } = { 109 | isLicenseVisible: "false", 110 | isShowcaseVisible: "true", 111 | isUserUploadable: "true", 112 | showCaseModelId: null, 113 | }; 114 | 115 | export const defaultSiteText: { [key in typeof siteTextIds[number]]: string } = 116 | { 117 | title: "POLY", 118 | mainPageGuideHead: "Welcome to Poly Web", 119 | mainPageGuideBody1: 120 | "This is a sample text. You can change this text from the admin page.", 121 | mainPageGuideBody2: 122 | "This is a sample text. You can change this text from the admin page.", 123 | mainPageGuideBody3: 124 | "This is a sample text. You can change this text from the admin page.", 125 | }; 126 | 127 | export async function initDBRecordsOnlyNotSet() { 128 | // init site config with default values if not set 129 | Object.entries(defaultSiteConfig).forEach(async ([key, val]) => { 130 | // find if the key is already set 131 | const isSet = await prismaClient.siteConfig.findFirst({ 132 | where: { 133 | id: key, 134 | }, 135 | }); 136 | if (isSet) return; 137 | await prismaClient.siteConfig.create({ 138 | data: { 139 | id: key, 140 | value: val || "", 141 | }, 142 | }); 143 | }); 144 | // init site text with default values if not set 145 | Object.entries(defaultSiteText).forEach(async ([key, val]) => { 146 | // find if the key is already set 147 | const isSet = await prismaClient.siteText.findFirst({ 148 | where: { 149 | id: key, 150 | }, 151 | }); 152 | if (isSet) return; 153 | await prismaClient.siteText.create({ 154 | data: { 155 | id: key, 156 | text: val, 157 | }, 158 | }); 159 | }); 160 | } 161 | -------------------------------------------------------------------------------- /customTypes/modelViewerJSX.ts: -------------------------------------------------------------------------------- 1 | import "@google/model-viewer"; 2 | 3 | declare global { 4 | namespace JSX { 5 | interface IntrinsicElements { 6 | "model-viewer": ModelViewerJSX & 7 | React.DetailedHTMLProps, HTMLElement>; 8 | } 9 | interface ModelViewerJSX { 10 | src: string; 11 | poster?: string; 12 | [key: string]: any; 13 | } 14 | } 15 | } 16 | 17 | interface ModelViewerElement extends Element { 18 | model: { 19 | materials: Array<{ 20 | name: string; 21 | pbrMetallicRoughness: { 22 | setBaseColorFactor: (x: [number, number, number, number]) => void; 23 | setMetallicFactor: (x: number) => void; 24 | setRoughnessFactor: (x: number) => void; 25 | 26 | baseColorTexture: null | { 27 | texture: { 28 | source: { 29 | setURI: (x: string) => void; 30 | }; 31 | }; 32 | }; 33 | metallicRoughnessTexture: null | { 34 | texture: { 35 | source: { 36 | setURI: (x: string) => void; 37 | }; 38 | }; 39 | }; 40 | // ... others 41 | }; 42 | }>; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /exec/2gltf2.py: -------------------------------------------------------------------------------- 1 | # 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) since 2017 UX3D GmbH 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | # 26 | # Imports 27 | # 28 | 29 | import os 30 | import sys 31 | import zipfile 32 | from os.path import basename 33 | 34 | import bpy 35 | from bpy import context 36 | 37 | # 38 | # Globals 39 | # 40 | 41 | # 42 | # Functions 43 | # 44 | 45 | bpy.ops.object.delete() 46 | 47 | current_directory = os.getcwd() 48 | 49 | force_continue = True 50 | 51 | for current_argument in sys.argv: 52 | 53 | if force_continue: 54 | if current_argument == '--': 55 | force_continue = False 56 | continue 57 | 58 | # 59 | 60 | root, current_extension = os.path.splitext(current_argument) 61 | current_basename = os.path.basename(root) 62 | if current_extension != ".abc" and current_extension != ".blend" and current_extension != ".dae" and current_extension != ".fbx" and current_extension != ".obj" and current_extension != ".ply" and current_extension != ".stl" and current_extension != ".usd" and current_extension != ".usda" and current_extension != ".usdc" and current_extension != ".wrl" and current_extension != ".x3d": 63 | continue 64 | 65 | if current_extension == ".abc": 66 | bpy.ops.wm.alembic_import(filepath=current_argument) 67 | 68 | if current_extension == ".blend": 69 | bpy.ops.wm.open_mainfile(filepath=current_argument) 70 | 71 | if current_extension == ".dae": 72 | bpy.ops.wm.collada_import(filepath=current_argument) 73 | 74 | if current_extension == ".fbx": 75 | bpy.ops.import_scene.fbx(filepath=current_argument) 76 | 77 | if current_extension == ".obj": 78 | #Changing material roughness 79 | bpy.ops.import_scene.obj(filepath=current_argument) 80 | bpy.context.view_layer.objects.active = bpy.context.selected_objects[0] 81 | mat = bpy.context.object.data.materials[0] 82 | base_node = mat.node_tree.nodes['Principled BSDF'] 83 | base_node.inputs['Roughness'].default_value = 1 84 | 85 | if current_extension == ".ply": 86 | bpy.ops.import_mesh.ply(filepath=current_argument) 87 | #Making empty image to bake texture 88 | 89 | image_name = bpy.context.active_object.name + '_tex' 90 | bpy.context.view_layer.objects.active = bpy.context.selected_objects[0] 91 | img = bpy.data.images.new(image_name,2048,2048) 92 | #Allocating material 93 | mat = bpy.data.materials.get("Material") 94 | if len(bpy.context.active_object.data.materials) == 0: 95 | bpy.context.active_object.data.materials.append(bpy.data.materials['Material']) 96 | else: 97 | bpy.context.active_object.data.materials[0] = bpy.data.materials['Material'] 98 | 99 | #Ready to bake 100 | if mat: 101 | mat.node_tree.nodes.new("ShaderNodeVertexColor") 102 | base_node = mat.node_tree.nodes['Principled BSDF'] 103 | base_node.inputs['Roughness'].default_value = 1 104 | mat.node_tree.links.new(mat.node_tree.nodes[2].outputs['Color'], base_node.inputs['Base Color']) 105 | 106 | mat.use_nodes = True 107 | texture_node =mat.node_tree.nodes.new('ShaderNodeTexImage') 108 | texture_node.name = 'Bake_node' 109 | texture_node.select = True 110 | mat.node_tree.nodes.active = texture_node 111 | texture_node.image = img #Assign the image to the node 112 | 113 | #UV Mapping 114 | bpy.ops.object.editmode_toggle() 115 | bpy.ops.mesh.select_all(action='SELECT') 116 | bpy.ops.uv.smart_project(angle_limit=1.55334) 117 | bpy.ops.object.editmode_toggle() 118 | 119 | #Baking Texture and save 120 | bpy.context.scene.render.engine = 'CYCLES' 121 | bpy.context.scene.cycles.bake_type = 'DIFFUSE' 122 | bpy.context.scene.render.bake.use_pass_direct = False 123 | bpy.context.scene.render.bake.use_pass_indirect = False 124 | bpy.context.scene.render.bake.margin = 0 125 | 126 | bpy.context.view_layer.objects.active = bpy.context.active_object 127 | bpy.ops.object.bake(type='DIFFUSE', save_mode='EXTERNAL') 128 | 129 | # img.save_render('scene.png') 130 | 131 | if mat: 132 | mat.node_tree.links.new(texture_node.outputs['Color'], base_node.inputs['Base Color']) 133 | 134 | #Making .zip file 135 | # zip_file = zipfile.ZipFile(file_loc_export+os.path.splitext(model)[0]+'.zip', "w") 136 | #zip_file.write(file_loc_export + 'scene.png', basename(file_loc_export + 'scene.png'), compress_type=zipfile.ZIP_DEFLATED) 137 | 138 | #Rendering thumbnail 139 | # bpy.ops.view3d.camera_to_view_selected() 140 | # bpy.ops.render.render(write_still = 1) 141 | 142 | #Creating gray-background thumnnail.png file 143 | # im = Image.open(scene.render.filepath) 144 | # nim = Image.new(mode = "RGBA", size = im.size, color = (240, 240, 240)) 145 | # nim.paste(im, (0, 0), im) 146 | # nim.save(scene.render.filepath) 147 | # zip_file.write(scene.render.filepath, basename(scene.render.filepath), compress_type=zipfile.ZIP_DEFLATED) 148 | 149 | #Exporting .glb file 150 | # bpy.ops.export_scene.gltf(filepath='scene.glb', export_materials='EXPORT', export_format='GLB') 151 | # zip_file.write(file_loc_export + 'scene.glb', basename(file_loc_export + 'scene.glb'), compress_type=zipfile.ZIP_DEFLATED) 152 | 153 | #Clearing material 154 | # for mat in bpy.context.active_object.data.materials: 155 | # for n in mat.node_tree.nodes: 156 | # if n.name == 'Bake_node': 157 | # mat.node_tree.nodes.remove(n) 158 | 159 | # bpy.ops.object.delete() 160 | # zip_file.close() 161 | 162 | if current_extension == ".stl": 163 | bpy.ops.import_mesh.stl(filepath=current_argument) 164 | 165 | if current_extension == ".usd" or current_extension == ".usda" or current_extension == ".usdc": 166 | bpy.ops.wm.usd_import(filepath=current_argument) 167 | 168 | if current_extension == ".wrl" or current_extension == ".x3d": 169 | bpy.ops.import_scene.x3d(filepath=current_argument) 170 | 171 | # 172 | 173 | export_file = current_directory + "/" + current_basename + ".gltf" 174 | print("Writing: '" + export_file + "'") 175 | bpy.ops.export_scene.gltf(filepath=export_file) 176 | -------------------------------------------------------------------------------- /exec/convert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ "$#" = 1 ] 3 | then 4 | blender -b -P exec/2gltf2.py -- "$1" 5 | else 6 | echo To glTF 2.0 converter. 7 | echo Supported file formats: .abc .blend .dae .fbx. .obj .ply .stl .usd .wrl .x3d 8 | echo 9 | echo 2gltf2.sh [filename] 10 | fi -------------------------------------------------------------------------------- /exec/picGen.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import bpy 5 | 6 | # remove cube and default light 7 | bpy.ops.object.delete() 8 | # remove defaults lights 9 | for obj in bpy.data.objects: 10 | if obj.type == 'LIGHT': 11 | obj.select_set(True) 12 | else: 13 | obj.select_set(False) 14 | bpy.ops.object.delete() 15 | 16 | 17 | # set camera clip range 18 | bpy.data.cameras['Camera'].clip_end = 1000 19 | bpy.data.cameras['Camera'].clip_start = 0.1 20 | 21 | force_continue = True 22 | 23 | for current_argument in sys.argv: 24 | 25 | if force_continue: 26 | if current_argument == '--': 27 | force_continue = False 28 | continue 29 | 30 | root, current_extension = os.path.splitext(current_argument) 31 | current_basename = os.path.basename(root) 32 | dirPath = os.path.dirname(current_argument) 33 | 34 | # if extension is not supported, skip 35 | if current_extension != ".gltf" and current_extension != ".glb": 36 | continue 37 | 38 | # import gltf object 39 | bpy.ops.import_scene.gltf(filepath=current_argument) 40 | 41 | # select all meshes 42 | for obj in bpy.data.objects: 43 | if obj.type == 'MESH': 44 | obj.select_set(True) 45 | else: 46 | obj.select_set(False) 47 | 48 | # select gltf object 49 | obj = bpy.context.view_layer.objects.active = bpy.context.selected_objects[0] 50 | 51 | if obj.dimensions: 52 | mutiplier = 1/obj.dimensions.x 53 | # resize object 54 | bpy.ops.transform.resize(value=(mutiplier, mutiplier, mutiplier)) 55 | 56 | # make camera look at the object to fit the object in the camera view 57 | bpy.ops.view3d.camera_to_view_selected() 58 | 59 | bpy.ops.transform.resize(value=(1.1, 1.1, 1.1)) 60 | 61 | # set the render background white 62 | bpy.data.worlds['World'].node_tree.nodes['Background'].inputs[0].default_value = (1, 1, 1, 1) 63 | bpy.data.worlds['World'].node_tree.nodes['Background'].inputs[1].default_value = 3 64 | 65 | # add a bright directional light 66 | # bpy.ops.object.light_add(type='SUN', location=(0, 0, 10), rotation=(-1, -1, -10)) 67 | # bpy.data.objects['Sun'].data.energy = 1 68 | 69 | # set the render resolution 70 | bpy.context.scene.render.resolution_x = 512 71 | bpy.context.scene.render.resolution_y = 384 72 | 73 | 74 | # set render path 75 | bpy.context.scene.render.filepath = os.path.join(dirPath, "thumbnail.png") 76 | 77 | # post process the render to clean the edges with cycles 78 | bpy.context.scene.render.engine = 'CYCLES' 79 | bpy.context.scene.cycles.samples = 100 80 | bpy.context.scene.cycles.max_bounces = 0 81 | bpy.context.scene.cycles.min_bounces = 0 82 | bpy.context.scene.cycles.diffuse_bounces = 0 83 | bpy.context.scene.cycles.glossy_bounces = 0 84 | bpy.context.scene.cycles.transparent_max_bounces = 0 85 | bpy.context.scene.cycles.transparent_min_bounces = 0 86 | 87 | # render the object 88 | bpy.ops.render.render(write_still=True) -------------------------------------------------------------------------------- /exec/thumbGen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ "$#" = 1 ] 3 | then 4 | blender -b -P exec/picGen.py -- "$1" 5 | else 6 | echo Usage : thumbGen.sh [filename] 7 | fi -------------------------------------------------------------------------------- /imgs/demo0_mainpage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo0_mainpage.jpg -------------------------------------------------------------------------------- /imgs/demo1_3d_view0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo1_3d_view0.jpg -------------------------------------------------------------------------------- /imgs/demo1_QRCodeImg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo1_QRCodeImg.jpg -------------------------------------------------------------------------------- /imgs/demo1_ar_view0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo1_ar_view0.jpg -------------------------------------------------------------------------------- /imgs/demo2_3d_view0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo2_3d_view0.jpg -------------------------------------------------------------------------------- /imgs/demo2_QRCodeImg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo2_QRCodeImg.jpg -------------------------------------------------------------------------------- /imgs/demo2_ar_view0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo2_ar_view0.jpg -------------------------------------------------------------------------------- /imgs/demo_mainpage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo_mainpage.jpg -------------------------------------------------------------------------------- /imgs/demo_viewerpage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/imgs/demo_viewerpage.jpg -------------------------------------------------------------------------------- /libs/client/AccessDB.ts: -------------------------------------------------------------------------------- 1 | import { ResponseQuery } from "@api/config"; 2 | import { ModelInfo } from "@customTypes/model"; 3 | import { User } from "@prisma/client"; 4 | import useSWR from "swr"; 5 | 6 | export function useUser() { 7 | const { data, error } = useSWR(`/api/users`, (url) => 8 | fetch(url).then((res) => res.json()) 9 | ); 10 | const loading = data === undefined && !error; 11 | return { loading, data, error }; 12 | } 13 | 14 | export function useModelInfos(filter?: { 15 | id?: string; 16 | uploader?: string; 17 | sort?: string; 18 | }) { 19 | const queryString = new URLSearchParams(filter).toString(); 20 | const { data, error } = useSWR( 21 | `/api/models?${queryString}`, 22 | (url) => fetch(url).then((res) => res.json()) 23 | ); 24 | const loading = !data && !error; 25 | return { loading, data, error }; 26 | } 27 | 28 | export function useModelInfo(modelId: string) { 29 | const { data, error } = useSWR( 30 | `/api/models?id=${modelId}`, 31 | (url) => fetch(url).then((res) => res.json()) 32 | ); 33 | const loading = !data && !error; 34 | const parsed = data?.[0] ?? null; 35 | return { loading, data: parsed, error }; 36 | } 37 | 38 | export function useSiteConfig() { 39 | const { data, error } = useSWR( 40 | "/api/config?config=true", 41 | (url) => fetch(url).then((res) => res.json()) 42 | ); 43 | const loading = !data && !error; 44 | return { loading, data, error }; 45 | } 46 | 47 | export function useUploadable() { 48 | const { 49 | data: config, 50 | error: configError, 51 | loading: configLoading, 52 | } = useSiteConfig(); 53 | const { data: user, error: userError, loading: userLoading } = useUser(); 54 | if (configLoading || userLoading || userError) return false; 55 | if (user?.role === "ADMIN" || user?.role === "DEVELOPER") return true; 56 | if (user?.role === "USER" && config?.config?.isUserUploadable === "true") 57 | return true; 58 | return false; 59 | } 60 | -------------------------------------------------------------------------------- /libs/client/Util.ts: -------------------------------------------------------------------------------- 1 | export function AddUnit( 2 | number?: string | number | null | BigInt 3 | ): string | null { 4 | if (!number) return null; 5 | const parsedNumber = typeof number === "string" ? +number : number; 6 | if (parsedNumber === NaN) return null; 7 | const numberUnitPair: [number, string][] = [ 8 | [1 << 30, "G"], 9 | [1 << 20, "M"], 10 | [1 << 10, "K"], 11 | ]; 12 | for (const [limit, Unit] of numberUnitPair) { 13 | if (parsedNumber >= limit) 14 | return (+parsedNumber.toString() / limit).toFixed(1).toString() + Unit; 15 | } 16 | return parsedNumber.toString(); 17 | } 18 | 19 | export const Categories: string[] = [ 20 | "Misc", 21 | "Furniture", 22 | "Architecture", 23 | "Animals", 24 | "Food", 25 | "Characters", 26 | "Nature", 27 | "Vehicles", 28 | "Scenes", 29 | "Accessories", 30 | "Health", 31 | "Instruments", 32 | "Plants", 33 | "Weapons", 34 | "Technology", 35 | ]; 36 | 37 | // export const Categories: string[] = [ 38 | // "Animals & Pets", 39 | // "Architecture", 40 | // "Art & Abstract", 41 | // "Cars & Vehicles", 42 | // "Cultural Heritage & History", 43 | // "Electronics & Gadgets", 44 | // "Fashion & Style", 45 | // "Food & Drink", 46 | // "Furniture & Home", 47 | // "Music", 48 | // "Nature & Plants", 49 | // "News & Politics", 50 | // "People", 51 | // "Places & Travel", 52 | // "Science & Technology", 53 | // "Sports & Fitness", 54 | // "Weapons & Military", 55 | // ]; 56 | -------------------------------------------------------------------------------- /libs/server/Authorization.ts: -------------------------------------------------------------------------------- 1 | import { Comment, Model, Role, User } from "@prisma/client"; 2 | 3 | type AuthorizationRequest = { 4 | operation: Operation; 5 | body: { 6 | requester?: User; // Not modifiable by user because this is encrypted value in jwt 7 | model?: Model; 8 | }; 9 | }; 10 | type Operation = { 11 | theme: "model" | "user" | "comment"; 12 | method: "create" | "read" | "update" | "delete"; 13 | }; 14 | 15 | export function hasRight( 16 | operation: Operation, 17 | requester?: User | null, 18 | model?: Model | null, 19 | comment?: Comment | null 20 | ): boolean { 21 | const role = requester?.role ?? Role.UNAUTHENTICATED; 22 | switch (role) { 23 | case Role.ADMIN: 24 | return true; 25 | case Role.USER: 26 | switch (operation.theme) { 27 | case "model": 28 | const userIsUploader = 29 | (model && requester && model.userId === requester.id) ?? false; 30 | switch (operation.method) { 31 | case "create": 32 | return true; 33 | case "delete": 34 | return userIsUploader; 35 | case "read": 36 | return true; 37 | case "update": 38 | return userIsUploader; 39 | } 40 | case "user": 41 | switch (operation.method) { 42 | case "create": 43 | return false; 44 | case "delete": 45 | return true; 46 | case "read": 47 | return true; 48 | case "update": 49 | return true; 50 | } 51 | case "comment": 52 | const userIsCommenter = 53 | (requester && comment && requester.id === comment.userId) ?? false; 54 | switch (operation.method) { 55 | case "create": 56 | return true; 57 | case "delete": 58 | return userIsCommenter; 59 | case "read": 60 | return true; 61 | case "update": 62 | return userIsCommenter; 63 | } 64 | } 65 | return false; 66 | case Role.UNAUTHENTICATED: 67 | return false; 68 | } 69 | return false; 70 | } 71 | -------------------------------------------------------------------------------- /libs/server/ModelConverter.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import { exec } from "child_process"; 3 | 4 | import path from "path/posix"; 5 | 6 | export const supportedExt = [ 7 | ".abc", 8 | ".blend", 9 | ".dae", 10 | ".fbx", 11 | ".obj", 12 | ".ply", 13 | ".stl", 14 | ".usd", 15 | ".wrl", 16 | ".x3d", 17 | ]; 18 | 19 | export async function executeConvertor( 20 | filePath: string, 21 | destDir: string = "/tmp" 22 | ) { 23 | if (!supportedExt.includes(path.extname(filePath))) { 24 | throw Error("Not supported type."); 25 | } 26 | await execute(`sh exec/convert.sh "${filePath}"`); 27 | const convertedFile = 28 | path.basename(filePath).split(".").slice(0, -1).join(".") + ".glb"; 29 | await execute(`mv "${convertedFile}" ${destDir}`).catch((e) => 30 | console.log(e) 31 | ); 32 | return `${destDir}/${convertedFile}`; 33 | } 34 | 35 | export async function execute(command: string) { 36 | return new Promise((res, rej) => { 37 | exec(command, (error, stdout, stderr) => { 38 | if (error) { 39 | rej(error); 40 | return; 41 | } 42 | res({ stdout, stderr }); 43 | }); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /libs/server/ServerFileHandling.ts: -------------------------------------------------------------------------------- 1 | import { PutObjectCommand } from "@aws-sdk/client-s3"; 2 | import { OptionalModel, UploadForm, ValidatorInfo } from "@customTypes/model"; 3 | import s3client, { deleteS3Files } from "@libs/server/s3client"; 4 | import { randomUUID } from "crypto"; 5 | 6 | import extract from "extract-zip"; 7 | import formidable from "formidable"; 8 | import { 9 | createReadStream, 10 | readdirSync, 11 | readFileSync, 12 | renameSync, 13 | statSync, 14 | } from "fs"; 15 | import { readdir, readFile, stat } from "fs/promises"; 16 | import { validateBytes } from "gltf-validator"; 17 | import path from "path"; 18 | import pathPosix, { extname } from "path/posix"; 19 | import { execute, executeConvertor } from "./ModelConverter"; 20 | 21 | type FormidableResult = { 22 | err: string; 23 | fields: formidable.Fields; 24 | files: formidable.Files; 25 | }; 26 | 27 | // 28 | 29 | export async function handlePOST( 30 | file: formidable.File, 31 | original: OptionalModel 32 | ) { 33 | const model = Object.assign({}, original); 34 | const uuid = randomUUID(); 35 | model.id = uuid; 36 | const extRes = await extractZip(uuid, file); 37 | const fileToConv = isThereModelToeconvert(extRes.newDirPath); 38 | if (fileToConv) { 39 | const convedFile = await executeConvertor(fileToConv, `/tmp/${uuid}`); 40 | } 41 | // if thumbnail.png is not exist in /tmp/${uuid}, use default.png 42 | if ( 43 | readdirSync(`/tmp/${uuid}`).find((val) => val === "thumbnail.png") === 44 | undefined 45 | ) { 46 | await generateThumbnail(`/tmp/${uuid}`); 47 | } 48 | 49 | model.name ??= trimExt(extRes.filename); 50 | model.zipSize = BigInt(extRes.zipSize); 51 | updateModel(model, await getModelFromDir(extRes.newDirPath)); 52 | updateModel( 53 | model, 54 | await getModelFromGltfReport( 55 | await getGltfInfo( 56 | path.join(extRes.newDirPath, model.modelFile ?? "Error") 57 | ) 58 | ) 59 | ); 60 | checkModel(model); 61 | uploadModelToS3(extRes.newDirPath, uuid); 62 | updatePrismaDB(model).catch((e) => { 63 | deleteS3Files(uuid); 64 | throw "Failed while updateDB"; 65 | }); 66 | } 67 | // devide multiple request and not multiple request -> [form, formidable.Files(Array)] 68 | 69 | // for each upload requirement ----------------------- 70 | 71 | // param : form(about model)?, formidable.File 72 | 73 | // 0. generate uuid 74 | 75 | // 0. update Model from form. do nothing if undefined. (name, tags, description, category) 76 | export function getModelFromForm(form: UploadForm): OptionalModel { 77 | return { 78 | category: form.category, 79 | description: form.description, 80 | tags: form.tag, 81 | name: form.name, 82 | }; 83 | } 84 | 85 | export function makeMaybeArrayToArray(target: T | T[]) { 86 | if (!(target instanceof Array)) { 87 | return [target]; 88 | } else { 89 | return target; 90 | } 91 | } 92 | 93 | // 1. extractZip (name) 94 | export async function extractZip( 95 | uuid: string, 96 | formidableFile: formidable.File 97 | ) { 98 | const fileInfo = await getOriginalNameAndPath(formidableFile); 99 | if (path.extname(fileInfo.loadedFile) !== ".zip") { 100 | throw Error("File is not .zip"); 101 | } 102 | const newDirPath = `/tmp/${uuid}`; 103 | const filename = fileInfo.originalName; 104 | const newZipPath = path.join(newDirPath, "model.zip"); 105 | await extract(fileInfo.loadedFile, { dir: newDirPath }); 106 | renameSync(fileInfo.loadedFile, newZipPath); 107 | const zipSize = await stat(newZipPath).then((res) => res.size); 108 | return { newDirPath, filename, zipSize, newZipPath }; 109 | } 110 | // name, zipSize 111 | 112 | // 2-1 Update Model from dir (vertex, triangle, gltf length) 113 | export async function getGltfInfo( 114 | gltf?: string | undefined 115 | ): Promise { 116 | if (!gltf) { 117 | throw Error("Model Path is not found."); 118 | } 119 | const asset = readFileSync(gltf); 120 | return validateBytes(new Uint8Array(asset)).then( 121 | (report: ValidatorInfo.RootObject) => { 122 | return report; 123 | } 124 | ); 125 | } 126 | // vertex=, tri, model size 127 | 128 | export async function getModelFromGltfReport( 129 | report: ValidatorInfo.RootObject 130 | ): Promise { 131 | return { 132 | modelTriangle: BigInt(report.info.totalTriangleCount), 133 | modelVertex: BigInt(report.info.totalVertexCount), 134 | }; 135 | } 136 | 137 | // 2-2 update Model from dir (name, thumbnail. usdz, usdzSize, zipSize, description) 138 | export async function getModelFromDir(dirPath: string): Promise { 139 | let model: OptionalModel = {}; 140 | let modelSize = 0; 141 | const files = await getFilesPath(dirPath); 142 | await Promise.all( 143 | files.map(async (file) => { 144 | const relativeFileName = path.relative(dirPath, file); 145 | const fileSzie = statSync(file).size; 146 | modelSize += fileSzie; 147 | if ([".gltf", ".glb"].includes(extname(relativeFileName))) { 148 | model.modelFile = relativeFileName; 149 | } 150 | if ("thumbnail.png" === relativeFileName) { 151 | model.thumbnail = relativeFileName; 152 | } 153 | if ([".usdz"].includes(extname(relativeFileName))) { 154 | model.modelUsdz = relativeFileName; 155 | model.usdzSize = BigInt(statSync(file).size); 156 | } 157 | if ("description.txt" === relativeFileName) { 158 | model.description = await readTextFile(file); 159 | } 160 | if ("model.zip" === relativeFileName) { 161 | modelSize -= fileSzie; 162 | } 163 | }) 164 | ); 165 | model.modelSize = BigInt(modelSize); 166 | return model; 167 | } 168 | //name??=, thum??= ... 169 | 170 | //update if not exist 171 | export function updateModel( 172 | target: OptionalModel, 173 | newObject: OptionalModel 174 | ): OptionalModel { 175 | target.id ??= newObject.id; 176 | target.category ??= newObject.category; 177 | target.description ??= newObject.description; 178 | target.modelFile ??= newObject.modelFile; 179 | target.modelUsdz ??= newObject.modelUsdz; 180 | target.modelSize ??= newObject.modelSize; 181 | target.modelTriangle ??= newObject.modelTriangle; 182 | target.usdzSize ??= newObject.usdzSize; 183 | target.modelVertex ??= newObject.modelVertex; 184 | target.name ??= newObject.name; 185 | target.tags ??= newObject.tags; 186 | target.thumbnail ??= newObject.thumbnail; 187 | return target; 188 | } 189 | // 190 | 191 | // 192 | 193 | // 3 chech Model requirement 194 | export function checkModel(model: OptionalModel) { 195 | if (!model.category) { 196 | throw Error("category is not exist"); 197 | } 198 | if (!model.id) { 199 | throw Error("modelId is not exist"); 200 | } 201 | if (!model.name) { 202 | throw Error("name is not exist"); 203 | } 204 | if (!model.userId) { 205 | throw Error("uploader is not exist"); 206 | } 207 | if (!model.modelFile) { 208 | throw Error("ModelFile is not exist"); 209 | } 210 | return model; 211 | } 212 | 213 | // 4-a update db 214 | // use prisma code 215 | export async function updatePrismaDB(model: OptionalModel) { 216 | if (!model.userId) throw Error("Cant find uploader id"); 217 | await prismaClient.model.create({ 218 | data: { 219 | name: model.name ?? "", 220 | category: model.category, 221 | description: model.description, 222 | id: model.id, 223 | userId: model.userId, 224 | modelFile: model.modelFile, 225 | modelVertex: model.modelVertex, 226 | modelTriangle: model.modelTriangle, 227 | zipSize: model.zipSize, 228 | modelUsdz: model.modelUsdz ?? undefined, 229 | usdzSize: model.usdzSize ?? undefined, 230 | thumbnail: model.thumbnail ?? undefined, 231 | modelSize: model.modelSize, 232 | tags: JSON.stringify(model.tags), 233 | }, 234 | }); 235 | } 236 | 237 | // 4-b sendToS3 238 | export async function uploadModelToS3(dirPath: string, uuid: string) { 239 | await Promise.all( 240 | ( 241 | await getFilesPath(dirPath) 242 | ).map((file) => { 243 | const stream = createReadStream(file); 244 | const filesParams = { 245 | Bucket: process.env.S3_BUCKET, 246 | Key: pathPosix.join(`models/${uuid}`, path.relative(dirPath, file)), 247 | Body: stream, 248 | }; 249 | return s3client.send(new PutObjectCommand(filesParams)); 250 | }) 251 | ); 252 | } 253 | 254 | async function readTextFile(textFile: string) { 255 | const buffer = await readFile(textFile, { encoding: "utf-8" }); 256 | return buffer; 257 | } 258 | 259 | export const getOriginalNameAndPath = (fileData: formidable.File) => { 260 | const parsed = fileData.toJSON(); 261 | return Promise.resolve({ 262 | originalName: parsed.originalFilename ?? "noName", 263 | loadedFile: parsed.filepath, 264 | }); 265 | }; 266 | 267 | const getFilesPath: (dir: string) => Promise = async ( 268 | dir: string 269 | ) => { 270 | const files = readdirSync(dir); 271 | const res = await Promise.all( 272 | files.map(async (file) => { 273 | const filePath = path.join(dir, file); 274 | const stats = statSync(filePath); 275 | if (stats.isDirectory()) return getFilesPath(filePath); 276 | else return filePath; 277 | }) 278 | ); 279 | if (res === undefined) return []; 280 | return res.reduce( 281 | (all, folderContents) => all.concat(folderContents), 282 | [] 283 | ); 284 | }; 285 | 286 | export function trimExt(name: string) { 287 | if (!name.includes(".")) { 288 | return name; 289 | } 290 | return name.split(".").slice(0, -1).join("."); 291 | } 292 | 293 | // useless for now. 294 | const dirSize: (dir: string) => Promise = async (dir: string) => { 295 | const files = await readdir(dir, { withFileTypes: true }); 296 | 297 | const paths = files.map(async (file) => { 298 | const dirPath = path.join(dir, file.name); 299 | 300 | if (file.isDirectory()) return await dirSize(dirPath); 301 | 302 | if (file.isFile()) { 303 | const { size } = await stat(dirPath); 304 | 305 | return size; 306 | } 307 | 308 | return 0; 309 | }); 310 | 311 | return (await Promise.all(paths)) 312 | .flat(Infinity) 313 | .reduce((i, size) => i + size, 0); 314 | }; 315 | 316 | function isThereModelToeconvert(dir: string): string | null { 317 | const supportedExt = [ 318 | ".abc", 319 | ".blend", 320 | ".dae", 321 | ".fbx", 322 | ".obj", 323 | ".ply", 324 | ".stl", 325 | ".usd", 326 | ".wrl", 327 | ".x3d", 328 | ]; 329 | const baseName = readdirSync(dir).find((val) => { 330 | return supportedExt.includes("." + val.split(".").pop()); 331 | }); 332 | return baseName ? path.join(dir, baseName) : null; 333 | } 334 | async function generateThumbnail(modelDir: string) { 335 | // find model file with .gltf or .glb 336 | const gltfFile = readdirSync(modelDir).find((val) => { 337 | return val.endsWith(".gltf") || val.endsWith(".glb"); 338 | }); 339 | if (!gltfFile) 340 | throw Error( 341 | "No gltf or glb file in modelDir. Failed to generate thumbnail" 342 | ); 343 | // execute command 344 | await execute(`sh exec/thumbGen.sh "${pathPosix.join(modelDir, gltfFile)}"`); 345 | return path.join(modelDir, "thumbnail.png"); 346 | } 347 | -------------------------------------------------------------------------------- /libs/server/prismaClient.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { NextApiRequest } from "next"; 3 | import { getSession } from "next-auth/react"; 4 | 5 | declare global { 6 | var prismaClient: PrismaClient; 7 | } 8 | 9 | global.prismaClient ??= new PrismaClient(); 10 | 11 | export default global.prismaClient; 12 | 13 | export const getUser = async (req: NextApiRequest) => { 14 | const session = await getSession({ req }); 15 | const email = session?.user?.email; 16 | const user = email 17 | ? await global.prismaClient.user.findUnique({ 18 | where: { email }, 19 | }) 20 | : null; 21 | return user; 22 | }; 23 | -------------------------------------------------------------------------------- /libs/server/s3client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeleteObjectCommand, 3 | GetObjectCommand, 4 | ListObjectsCommand, 5 | S3Client, 6 | } from "@aws-sdk/client-s3"; 7 | 8 | const id = String(process.env.S3_KEY_ID); 9 | const key = String(process.env.S3_KEY); 10 | const region = String(process.env.S3_REGION); 11 | 12 | declare global { 13 | var s3Client: S3Client; 14 | } 15 | 16 | global.s3Client ??= new S3Client({ 17 | credentials: { 18 | accessKeyId: id, 19 | secretAccessKey: key, 20 | }, 21 | region: region, 22 | }); 23 | 24 | export default global.s3Client; 25 | 26 | export const getSavedModelList = async () => { 27 | const objects = await s3Client.send( 28 | new ListObjectsCommand({ 29 | Bucket: process.env.S3_BUCKET, 30 | Prefix: `models/`, 31 | }) 32 | ); 33 | const modelUuids = 34 | objects.Contents?.reduce((prev, cur) => { 35 | prev.add(cur.Key?.split("/")[1] ?? cur.Key ?? "error"); 36 | return prev; 37 | }, new Set()) ?? new Set(); 38 | return modelUuids; 39 | }; 40 | 41 | export const deleteS3Files = async (uuid: string) => { 42 | const objects = await s3Client.send( 43 | new ListObjectsCommand({ 44 | Bucket: process.env.S3_BUCKET, 45 | Prefix: `models/${uuid}`, 46 | }) 47 | ); 48 | if (!objects.Contents) throw "Can't find target."; 49 | Promise.all( 50 | objects.Contents.map((file) => 51 | s3Client.send( 52 | new DeleteObjectCommand({ 53 | Bucket: process.env.S3_BUCKET, 54 | Key: file.Key, 55 | }) 56 | ) 57 | ) 58 | ); 59 | }; 60 | 61 | export const downloadS3Files = async (uuid: string, file = "model.zip") => { 62 | const objectBuffer = await s3Client.send( 63 | new GetObjectCommand({ 64 | Bucket: process.env.S3_BUCKET, 65 | Key: `models/${uuid}/${file}`, 66 | }) 67 | ); 68 | return objectBuffer; 69 | }; 70 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from "next-auth/jwt"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function middleware(req: NextRequest) { 5 | const token = await getToken({ req }); 6 | if (!token || !(token.role === "ADMIN" || token.role === "DEVELOPER")) { 7 | req.nextUrl.pathname = "/models"; 8 | return NextResponse.redirect(req.nextUrl); 9 | } 10 | } 11 | 12 | export const config = { 13 | matcher: "/dev", 14 | }; 15 | -------------------------------------------------------------------------------- /module/gltf-validator.d.ts: -------------------------------------------------------------------------------- 1 | declare module "gltf-validator"; 2 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | // reactStrictMode: true, 4 | env: { 5 | S3_KEY_ID: process.env.S3_KEY_ID, 6 | S3_KEY: process.env.S3_KEY, 7 | S3_REGION: process.env.S3_REGION, 8 | S3_BUCKET: process.env.S3_BUCKET, 9 | }, 10 | async rewrites() { 11 | return [ 12 | { 13 | source: `/getResource/:path*`, 14 | destination: process.env.RESOURCE_URL, 15 | }, 16 | ]; 17 | }, 18 | images: { 19 | domains: ["lh3.googleusercontent.com", "avatars.githubusercontent.com"], 20 | }, 21 | webpack5: true, 22 | webpack: (config) => { 23 | config.resolve.fallback = { fs: false }; 24 | 25 | return config; 26 | }, 27 | }; 28 | module.exports = nextConfig; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "poly", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "dev:db-push": "dotenv -e .env.development -- npx prisma db push", 11 | "dev:studio": "dotenv -e .env.development -- npx prisma studio", 12 | "remo-dev:db-push": "dotenv -e .env.remodev -- npx prisma db push", 13 | "remo-dev:studio": "dotenv -e .env.remodev -- npx prisma studio" 14 | }, 15 | "dependencies": { 16 | "@aws-sdk/client-s3": "^3.135.0", 17 | "@google/model-viewer": "^1.12.0", 18 | "@next-auth/prisma-adapter": "^1.0.4", 19 | "@prisma/client": "^4.3.1", 20 | "@react-three/drei": "^9.14.3", 21 | "@react-three/fiber": "^8.0.27", 22 | "@react-three/xr": "^3.5.0", 23 | "@supabase/supabase-js": "^1.35.4", 24 | "@types/extract-zip": "^2.0.1", 25 | "@types/uuid": "^8.3.4", 26 | "dotenv-cli": "^6.0.0", 27 | "eslint-config-prettier": "^8.5.0", 28 | "extract-zip": "^2.0.1", 29 | "formidable": "^2.0.1", 30 | "gltf-validator": "^2.0.0-dev.3.9", 31 | "install": "^0.13.0", 32 | "iron-session": "^6.1.3", 33 | "lodash": "^4.17.21", 34 | "moment": "^2.29.4", 35 | "next": "12.2.0", 36 | "next-auth": "^4.10.2", 37 | "next-swagger-doc": "^0.3.6", 38 | "prisma": "^4.3.1", 39 | "react": "18.2.0", 40 | "react-dom": "18.2.0", 41 | "react-dropzone": "^14.2.2", 42 | "react-hook-form": "^7.33.0", 43 | "sharp": "^0.30.7", 44 | "swagger-ui-react": "^4.15.5", 45 | "swr": "^1.3.0", 46 | "three": "^0.141.0" 47 | }, 48 | "devDependencies": { 49 | "@types/formidable": "^2.0.5", 50 | "@types/lodash": "^4.14.186", 51 | "@types/moment": "^2.13.0", 52 | "@types/node": "18.0.0", 53 | "@types/react": "18.0.14", 54 | "@types/react-dom": "18.0.5", 55 | "@types/swagger-ui-react": "^4.11.0", 56 | "@types/three": "^0.141.0", 57 | "autoprefixer": "^10.4.7", 58 | "eslint": "8.18.0", 59 | "eslint-config-next": "12.2.0", 60 | "postcss": "^8.4.14", 61 | "tailwindcss": "^3.1.4", 62 | "typescript": "4.7.4" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | function Custom404() { 2 | return

404 - Page Not Found 😢

; 3 | } 4 | 5 | export default Custom404; 6 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@components/header"; 2 | import "@styles/globals.css"; 3 | import { SessionProvider } from "next-auth/react"; 4 | import type { AppProps } from "next/app"; 5 | 6 | function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) { 7 | return ( 8 | 9 |
10 | 11 | 12 | ); 13 | } 14 | 15 | export default MyApp; 16 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import prisma from "@libs/server/prismaClient"; 2 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 3 | import NextAuth from "next-auth"; 4 | import GithubProvider from "next-auth/providers/github"; 5 | import GoogleProvider from "next-auth/providers/google"; 6 | 7 | // NextAuth Documentation is here : https://next-auth.js.org/configuration/options 8 | 9 | export default NextAuth({ 10 | adapter: PrismaAdapter(prisma), 11 | providers: [ 12 | GithubProvider({ 13 | clientId: process.env.GITHUB_ID ?? "", 14 | clientSecret: process.env.GITHUB_SECRET ?? "", 15 | }), 16 | GoogleProvider({ 17 | clientId: process.env.GOOGLE_ID ?? "", 18 | clientSecret: process.env.GOOGLE_SECRET ?? "", 19 | }), 20 | ], 21 | callbacks: { 22 | async jwt({ token, account, isNewUser, profile, user }) { 23 | // account(oauthInfo), profile, user(dbUserInfo), isNewUser are only passed one time after user signs in. 24 | if (user) { 25 | token.role = user.role; 26 | } 27 | return token; 28 | }, 29 | async session({ session, token }) { 30 | session.role = token.role; 31 | return session; 32 | }, 33 | }, 34 | session: { 35 | strategy: "jwt", 36 | maxAge: 24 * 60 * 60, // session duration. logout after 24h idle. 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /pages/api/comment/index.ts: -------------------------------------------------------------------------------- 1 | import { hasRight } from "@libs/server/Authorization"; 2 | import { getUser } from "@libs/server/prismaClient"; 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | 5 | const allowedMethod = ["POST", "DELETE"]; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | if (!allowedMethod.includes(req.method ?? "")) { 12 | res.status(405).end(); 13 | return; 14 | } 15 | const user = await getUser(req); 16 | const modelId = getAnyQueryValueOfKey(req, "modelId"); 17 | const commentId = getAnyQueryValueOfKey(req, "commentId"); 18 | if (req.method === "POST") { 19 | if (!modelId) { 20 | res 21 | .status(400) 22 | .json({ ok: false, error: "Can't find the model id in query." }); 23 | return; 24 | } 25 | if (!user) { 26 | res.json({ 27 | ok: false, 28 | message: "Please log in for commenting.", 29 | }); 30 | return; 31 | } 32 | const form = JSON.parse(req.body); 33 | await prismaClient.comment.create({ 34 | data: { 35 | text: form.text, 36 | userId: user.id, 37 | modelId: modelId, 38 | }, 39 | }); 40 | res.json({ ok: true, message: "success!" }); 41 | } else if (req.method === "DELETE") { 42 | if (!commentId) { 43 | res.json({ ok: false, message: "잘못된 삭제요청입니다." }); 44 | return; 45 | } 46 | const comment = await prismaClient.comment.findUnique({ 47 | where: { 48 | id: +commentId, 49 | }, 50 | }); 51 | if ( 52 | !hasRight({ method: "delete", theme: "comment" }, user, null, comment) 53 | ) { 54 | res.json({ 55 | ok: false, 56 | message: "삭제권한이 없습니다.", 57 | }); 58 | return; 59 | } 60 | await prismaClient.comment.delete({ 61 | where: { 62 | id: +commentId, 63 | }, 64 | }); 65 | res.json({ ok: true, message: "success!" }); 66 | } 67 | } 68 | 69 | export function getAnyQueryValueOfKey(req: NextApiRequest, key: string) { 70 | return Array.isArray(req.query[key]) 71 | ? req.query[key]?.[0] 72 | : (req.query[key] as string); 73 | } 74 | -------------------------------------------------------------------------------- /pages/api/config/index.ts: -------------------------------------------------------------------------------- 1 | import { SiteConfigProps, SiteTextProps } from "@customTypes/model"; 2 | import { Role } from "@prisma/client"; 3 | import { NextApiRequest, NextApiResponse } from "next"; 4 | import { getSession } from "next-auth/react"; 5 | 6 | const allowedMethod = ["GET", "PATCH"]; 7 | 8 | export type SiteConfig = { 9 | texts: SiteTextProps; 10 | }; 11 | 12 | const Query = ["texts", "config"] as const; 13 | 14 | export type RequestQuery = Partial<{ 15 | [key in typeof Query[number]]: string | string[]; 16 | }>; 17 | 18 | export type ResponseQuery = { 19 | texts?: Partial; 20 | config?: Partial; 21 | }; 22 | 23 | export default async function handler( 24 | req: NextApiRequest, 25 | res: NextApiResponse 26 | ) { 27 | if (!allowedMethod.includes(req.method!)) { 28 | return res.status(405).end(); 29 | } 30 | if (req.method === "GET") { 31 | return await handleGet(req, res); 32 | } else if (req.method === "PATCH") { 33 | return await handlePatch(req, res); 34 | } 35 | return res.end("error"); 36 | } 37 | 38 | const handleGet = async (req: NextApiRequest, res: NextApiResponse) => { 39 | const query: RequestQuery = req.query; 40 | const answer: ResponseQuery = {}; 41 | if (query.texts === "true") { 42 | const texts = await prismaClient.siteText.findMany(); 43 | answer.texts = parseDBGetResult(texts, "text"); 44 | } 45 | if (query.config === "true") { 46 | const config = await prismaClient.siteConfig.findMany(); 47 | answer.config = parseDBGetResult(config, "value"); 48 | } 49 | res.json(answer); 50 | return; 51 | }; 52 | 53 | const parseDBGetResult = ( 54 | result: { id: string; [key: string]: string }[], 55 | column: string 56 | ) => { 57 | return result.reduce((prev: { [key: string]: string }, cur) => { 58 | prev[cur.id] = cur[column]; 59 | return prev; 60 | }, {}); 61 | }; 62 | 63 | const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => { 64 | const session = await getSession({ req }); 65 | if (!(await IsUserAdmin(session))) { 66 | return res.status(403).end(); 67 | } 68 | const result = await Promise.allSettled( 69 | Object.entries(req.body as Object).map(([key, val]) => { 70 | return prismaClient.siteText.update({ 71 | where: { id: key }, 72 | data: { text: val }, 73 | }); 74 | }) 75 | ); 76 | res.json(result); 77 | return; 78 | }; 79 | 80 | const IsUserAdmin = async (session: any) => { 81 | const email = session?.user?.email; 82 | const role = await prismaClient.user 83 | .findUnique({ 84 | where: { email: email ?? "" }, 85 | select: { 86 | role: true, 87 | }, 88 | }) 89 | .then((res) => res?.role); 90 | return role === Role.ADMIN; 91 | }; 92 | -------------------------------------------------------------------------------- /pages/api/db/initialize.ts: -------------------------------------------------------------------------------- 1 | // initialize nextjs api 2 | import { initDBRecordsOnlyNotSet } from "@customTypes/model"; 3 | import { getUser } from "@libs/server/prismaClient"; 4 | import { NextApiRequest, NextApiResponse } from "next"; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | const { method } = req; 11 | 12 | switch (method) { 13 | case "GET": 14 | // if user is not ADMIN, return 403 15 | const user = await getUser(req); 16 | if (user?.role !== "ADMIN" && user?.role !== "DEVELOPER") { 17 | res.status(403).json({ message: "Not authorized" }); 18 | return; 19 | } 20 | try { 21 | await initDBRecordsOnlyNotSet(); 22 | res.status(200).json({ ok: true }); 23 | } catch (error) { 24 | res.status(400).json({ ok: false, message: "init DB failed" }); 25 | } 26 | break; 27 | default: 28 | res.setHeader("Allow", ["GET"]); 29 | res.status(405).end(`Method ${method} Not Allowed`); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pages/api/models/[id].ts: -------------------------------------------------------------------------------- 1 | import { getAnyQueryValueOfKey } from "@api/comment"; 2 | import { UploadForm } from "@customTypes/model"; 3 | import { hasRight } from "@libs/server/Authorization"; 4 | import prismaClient, { getUser } from "@libs/server/prismaClient"; 5 | import { deleteS3Files, downloadS3Files } from "@libs/server/s3client"; 6 | import { NextApiRequest, NextApiResponse } from "next"; 7 | import internal from "stream"; 8 | 9 | const allowedMethod = ["GET", "DELETE", "PATCH", "POST"]; 10 | 11 | // If CDN is adapted, this api can improve performance by checking authority then passing download request to CDN server. 12 | // still, this api is handling download request. 13 | export const config = { 14 | api: { 15 | responseLimit: false, 16 | }, 17 | }; 18 | 19 | export default async function handler( 20 | req: NextApiRequest, 21 | res: NextApiResponse 22 | ) { 23 | if (!allowedMethod.includes(req.method ?? "")) { 24 | res.status(405).end(); 25 | return; 26 | } 27 | const modelId = Array.isArray(req.query.id) ? req.query.id[0] : req.query.id; 28 | 29 | if (!modelId) { 30 | res 31 | .status(400) 32 | .json({ ok: false, error: "Can't find the model id in query." }); 33 | return; 34 | } 35 | const user = await getUser(req); 36 | if (req.method === "GET") { 37 | const DAYILY_DOWNLOAD_LIMIT = 100; 38 | if (!user) { 39 | res.json({ 40 | ok: false, 41 | message: "Please log in for download", 42 | }); 43 | return; 44 | } 45 | if (user.role === "UNAUTHENTICATED") { 46 | res.json({ 47 | ok: false, 48 | message: "You don't have a permission to download a model.", 49 | }); 50 | return; 51 | } 52 | const { _count } = await prismaClient.log.aggregate({ 53 | // May be inefficient query. find another algorithm later. 54 | where: { 55 | createdAt: { 56 | gte: new Date(new Date().toDateString()), // truncated time(begining of today) depends on server location. 57 | }, 58 | action: "MODEL_DOWNLOAD", 59 | userId: user.id, 60 | }, 61 | _count: true, 62 | }); 63 | if (_count >= DAYILY_DOWNLOAD_LIMIT) { 64 | res.json({ 65 | ok: false, 66 | message: `Your download count exceed daily limit (max : ${DAYILY_DOWNLOAD_LIMIT})`, 67 | }); 68 | return; 69 | } 70 | 71 | const fileName = await prismaClient.model.findUnique({ 72 | where: { 73 | id: modelId, 74 | }, 75 | }); 76 | 77 | // download file from s3 78 | const objectbuffer = await downloadS3Files( 79 | modelId, 80 | fileName?.modelFile 81 | ).catch((error) => { 82 | throw Error("Can't find model."); 83 | }); 84 | if (objectbuffer instanceof Error) { 85 | res.json({ 86 | ok: false, 87 | message: `Can't find model with ID ${modelId}`, 88 | }); 89 | return; 90 | } 91 | if ( 92 | !objectbuffer.Body || 93 | !(objectbuffer.Body instanceof internal.Readable) 94 | ) { 95 | res.status(500).json({ 96 | ok: false, 97 | message: "failed to download while connecting storage server", 98 | }); 99 | return; 100 | } 101 | objectbuffer.Body.pipe(res); 102 | // then create new log 103 | await prismaClient.log.create({ 104 | data: { 105 | action: "MODEL_DOWNLOAD", 106 | userId: user.id, 107 | }, 108 | }); 109 | return; 110 | } 111 | if (req.method === "DELETE") { 112 | const model = await prismaClient.model.findUnique({ 113 | where: { 114 | id: modelId, 115 | }, 116 | }); 117 | if (model === null) { 118 | res.status(404).json({ ok: false, message: "Can't find the model." }); 119 | return; 120 | } 121 | if ( 122 | !hasRight( 123 | { 124 | method: "delete", 125 | theme: "model", 126 | }, 127 | user, 128 | model 129 | ) 130 | ) { 131 | res 132 | .status(403) 133 | .json({ ok: false, message: "You don't have a permission." }); 134 | return; 135 | } 136 | try { 137 | deleteS3Files(modelId); 138 | await prismaClient.model.delete({ 139 | where: { 140 | id: modelId, 141 | }, 142 | }); 143 | res.json({ ok: true, message: "delete success!" }); 144 | return; 145 | } catch (e) { 146 | res.status(500).json({ ok: false, message: "Failed while deleting." }); 147 | return; 148 | } 149 | } 150 | if (req.method === "PATCH") { 151 | const model = await prismaClient.model.findUnique({ 152 | where: { 153 | id: modelId, 154 | }, 155 | }); 156 | if (model === null) { 157 | res.status(404).json({ ok: false, message: "Can't find the model." }); 158 | return; 159 | } 160 | if ( 161 | !hasRight( 162 | { 163 | method: "update", 164 | theme: "model", 165 | }, 166 | user, 167 | model 168 | ) 169 | ) { 170 | res 171 | .status(403) 172 | .json({ ok: false, message: "You don't have a permission." }); 173 | return; 174 | } 175 | const form: UploadForm | undefined = JSON.parse(req.body); 176 | if (!form) { 177 | res.json({ ok: false, message: "Form data is not defined." }); 178 | return; 179 | } 180 | try { 181 | await prismaClient.model.update({ 182 | where: { 183 | id: modelId, 184 | }, 185 | data: { 186 | name: form.name, 187 | category: form.category, 188 | description: form.description, 189 | }, 190 | }); 191 | res.json({ ok: true, message: "update success!" }); 192 | return; 193 | } catch (e) { 194 | res.status(500).json({ ok: false, message: "Failed while updating db." }); 195 | return; 196 | } 197 | } 198 | if (req.method === "POST") { 199 | const key = getAnyQueryValueOfKey(req, "view"); 200 | if (key) { 201 | const model = await prismaClient.model.update({ 202 | where: { 203 | id: modelId, 204 | }, 205 | data: { 206 | viewed: { 207 | increment: 1, 208 | }, 209 | }, 210 | }); 211 | } 212 | res.json({ ok: true }); 213 | return; 214 | } 215 | res.status(500).json({ ok: false, message: "Failed handling request." }); 216 | } 217 | -------------------------------------------------------------------------------- /pages/api/models/index.ts: -------------------------------------------------------------------------------- 1 | import { getAnyQueryValueOfKey } from "@api/comment"; 2 | import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; 3 | import { ModelInfo, OptionalModel, UploadForm } from "@customTypes/model"; 4 | import { Categories } from "@libs/client/Util"; 5 | import { hasRight } from "@libs/server/Authorization"; 6 | import prismaClient, { getUser } from "@libs/server/prismaClient"; 7 | import s3client, { deleteS3Files } from "@libs/server/s3client"; 8 | import { 9 | getModelFromForm, 10 | getOriginalNameAndPath, 11 | handlePOST, 12 | makeMaybeArrayToArray, 13 | updateModel, 14 | } from "@libs/server/ServerFileHandling"; 15 | import { Model, User } from "@prisma/client"; 16 | import formidable from "formidable"; 17 | import { createReadStream, ReadStream } from "fs"; 18 | import { NextApiRequest, NextApiResponse } from "next"; 19 | import { getSession } from "next-auth/react"; 20 | import path from "path/posix"; 21 | 22 | export const config = { 23 | api: { 24 | bodyParser: false, 25 | }, 26 | }; 27 | 28 | (BigInt.prototype as any).toJSON = function () { 29 | return this.toString(); 30 | }; 31 | 32 | type FormidableResult = { 33 | err: string; 34 | fields: formidable.Fields; 35 | files: formidable.Files; 36 | }; 37 | 38 | const allowedMethod = ["GET", "POST", "PATCH", "DELETE"]; 39 | 40 | export default async function handler( 41 | req: NextApiRequest, 42 | res: NextApiResponse 43 | ) { 44 | const session = await getSession({ req }); 45 | if (!allowedMethod.includes(req.method ?? "")) { 46 | res.status(405).end(); 47 | return; 48 | } 49 | if (req.method === "GET") { 50 | // respone specified model info 51 | if (req.query.id) { 52 | const model = await prismaClient.model.findUnique({ 53 | where: { id: req.query.id as string }, 54 | include: { 55 | Comment: { 56 | orderBy: { createdAt: "desc" }, 57 | include: { 58 | commenter: { 59 | select: { 60 | name: true, 61 | image: true, 62 | createdAt: true, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | }); 69 | if (!model) { 70 | res.status(404).end(); 71 | return; 72 | } 73 | if (model.blinded === true) { 74 | res.status(403).end(); 75 | return; 76 | } 77 | res.json([makeModelInfo(model)]); 78 | return; 79 | } else if (req.query.uploader) { 80 | // respone specific uploader's models info 81 | const uploaders = 82 | typeof req.query.uploader === "string" 83 | ? [req.query.uploader] 84 | : req.query.uploader; 85 | const querys = uploaders.map((uploaderId) => { 86 | return { 87 | uploader: { 88 | id: uploaderId, 89 | }, 90 | }; 91 | }); 92 | const model = await prismaClient.model.findMany({ 93 | where: { 94 | OR: querys, 95 | }, 96 | include: { 97 | _count: { 98 | select: { 99 | Comment: true, 100 | }, 101 | }, 102 | }, 103 | }); 104 | res.json(makeModelInfos(model)); 105 | return; 106 | } else if (req.query.sort) { 107 | let errorMessage = undefined; 108 | 109 | const { sort, category, filterByName, orderBy } = req.query; 110 | let options = { 111 | where: { 112 | name: { 113 | contains: filterByName?.toString(), 114 | }, 115 | }, 116 | orderBy: { 117 | [`${sort}`]: orderBy, 118 | }, 119 | include: { 120 | _count: { 121 | select: { 122 | Comment: true, 123 | }, 124 | }, 125 | }, 126 | }; 127 | if (category) { 128 | if (Categories.includes(category?.toString())) { 129 | const where = Object.assign(options.where, { 130 | category: category.toString().toUpperCase(), 131 | }); 132 | 133 | options.where = where; 134 | } 135 | } 136 | 137 | const modelList = await prismaClient.model.findMany(options); 138 | 139 | if (modelList?.length === 0) { 140 | if (filterByName) { 141 | errorMessage = `We couldn't find any matches for "${ 142 | req.query.filterByName 143 | }"${category ? ` in ${category}` : ""}`; 144 | } else if (category) { 145 | errorMessage = `We couldn't find any matches in "${category}"`; 146 | } 147 | 148 | res.status(404).json({ 149 | data: modelList, 150 | error: errorMessage, 151 | }); 152 | return; 153 | } 154 | const parsedList = makeModelInfos(modelList); 155 | res.status(200).json({ data: parsedList, error: undefined }); 156 | return; 157 | } 158 | const modelList = await prismaClient.model.findMany({ 159 | orderBy: { 160 | createdAt: "desc", 161 | }, 162 | include: { 163 | _count: { 164 | select: { 165 | Comment: true, 166 | }, 167 | }, 168 | uploader: { 169 | select: { 170 | name: true, 171 | }, 172 | }, 173 | }, 174 | }); 175 | const parsedList = makeModelInfos(modelList); 176 | res.status(200).json(parsedList); 177 | } else if (req.method === "POST") { 178 | // authorize client then upload model. db update return model id. 179 | const isLogined = !!session; 180 | if (!isLogined) { 181 | res.status(401).send("Login first"); 182 | return; 183 | } 184 | 185 | const user = await prismaClient.user.findUnique({ 186 | where: { 187 | email: session.user?.email ?? undefined, // if undefined, search nothing 188 | }, 189 | }); 190 | if (user === null) { 191 | res.status(401).end(); 192 | return; 193 | } 194 | 195 | if ( 196 | getAnyQueryValueOfKey(req, "modUsdz") === "true" && 197 | getAnyQueryValueOfKey(req, "modelId") 198 | ) { 199 | const modelId = getAnyQueryValueOfKey(req, "modelId"); 200 | const model = await prismaClient.model.findUnique({ 201 | where: { 202 | id: modelId, 203 | }, 204 | }); 205 | if (model === null) { 206 | res.status(404).json({ ok: false, message: "Can't find the model." }); 207 | return; 208 | } 209 | if ( 210 | !hasRight( 211 | { 212 | method: "update", 213 | theme: "model", 214 | }, 215 | user, 216 | model 217 | ) 218 | ) { 219 | res 220 | .status(403) 221 | .json({ ok: false, message: "You don't have a permission." }); 222 | return; 223 | } 224 | const formidable = await getFormidableFileFromReq(req, { 225 | multiples: true, 226 | maxFileSize: 150 << 20, // 100MB for zip file 227 | keepExtensions: true, 228 | }); 229 | const files = makeMaybeArrayToArray( 230 | formidable.files.file 231 | ); 232 | const deleteFile = (uuid: string, relPath: string) => { 233 | return s3client.send( 234 | new DeleteObjectCommand({ 235 | Bucket: process.env.S3_BUCKET, 236 | Key: path.join(`models/${uuid}`, relPath), 237 | }) 238 | ); 239 | }; 240 | if (model.modelUsdz) { 241 | await deleteFile(model.id, model.modelUsdz); 242 | } 243 | const fileInfo = await getOriginalNameAndPath(files[0]); 244 | const fileStream = createReadStream(fileInfo.loadedFile); 245 | const uploadFile = ( 246 | uuid: string, 247 | usdzName: string, 248 | stream: ReadStream 249 | ) => { 250 | const filesParams = { 251 | Bucket: process.env.S3_BUCKET, 252 | Key: path.join(`models/${uuid}`, usdzName), 253 | Body: stream, 254 | }; 255 | return s3client.send(new PutObjectCommand(filesParams)); 256 | }; 257 | await uploadFile(model.id, fileInfo.originalName, fileStream); 258 | await prismaClient.model.update({ 259 | where: { id: modelId }, 260 | data: { modelUsdz: fileInfo.originalName }, 261 | }); 262 | res.json({ ok: true }); 263 | return; 264 | } else { 265 | if ( 266 | // if don't have right, reply code 403. 267 | !hasRight( 268 | { 269 | theme: "model", 270 | method: "create", 271 | }, 272 | user 273 | ) 274 | ) { 275 | res.status(403).json({ ok: false, message: "로그인이 필요합니다." }); 276 | return; 277 | } 278 | // upload to s3 279 | const isAdminOrDev = user.role === "ADMIN" || user.role === "DEVELOPER"; 280 | const option: formidable.Options | undefined = isAdminOrDev 281 | ? { multiples: true, maxFileSize: Infinity, keepExtensions: true } 282 | : undefined; 283 | const formidable = await getFormidableFileFromReq(req, option).catch( 284 | (e) => "Failed" 285 | ); 286 | 287 | if (typeof formidable === "string") { 288 | res.json({ 289 | ok: false, 290 | message: "Failed to parse your request. Check your model size.", 291 | }); 292 | return; 293 | } 294 | const doesFormExist = !!formidable.fields.form; 295 | const model: OptionalModel = {}; 296 | model.userId = user.id; 297 | if (doesFormExist) { 298 | const form: UploadForm = JSON.parse(formidable.fields.form as string); 299 | updateModel(model, getModelFromForm(form)); 300 | } else { 301 | model.category = "MISC"; // add if form data is not exist. 302 | } 303 | const files = makeMaybeArrayToArray( 304 | formidable.files.file 305 | ); 306 | const results = await Promise.allSettled( 307 | files.map((file) => handlePOST(file, model)) 308 | ); 309 | res.json({ results }); 310 | } 311 | } else if (req.method === "PATCH") { 312 | const user = await getUser(req); 313 | if (getAnyQueryValueOfKey(req, "devMode") === "true") { 314 | if (getAnyQueryValueOfKey(req, "massive") === "true") { 315 | const { 316 | err, 317 | fields: { modelList }, 318 | } = await getFormidableFileFromReq(req); 319 | const blindVal = 320 | getAnyQueryValueOfKey(req, "blind") === "true" ? true : false; 321 | const mlist = modelList ?? []; 322 | await prismaClient.model.updateMany({ 323 | where: { 324 | id: { in: mlist as string[] }, 325 | }, 326 | data: { 327 | blinded: blindVal, 328 | }, 329 | }); 330 | res.json({ ok: true }); 331 | } else { 332 | const { 333 | err, 334 | fields: { model, blind }, 335 | } = await getFormidableFileFromReq(req); 336 | if (err) { 337 | res 338 | .status(500) 339 | .json({ ok: false, message: "Failed parsing request." }); 340 | return; 341 | } 342 | if ( 343 | !hasRight( 344 | { method: "update", theme: "model" }, 345 | user, 346 | await prismaClient.model.findUnique({ 347 | where: { id: model as string }, 348 | }) 349 | ) 350 | ) { 351 | res.status(403).json({ ok: false }); 352 | return; 353 | } 354 | 355 | await prismaClient.model.update({ 356 | where: { 357 | id: model as string, 358 | }, 359 | data: { 360 | blinded: blind === "true" ? true : false, 361 | }, 362 | }); 363 | res.end(); 364 | return; 365 | } 366 | } 367 | res.status(400).end(); 368 | return; 369 | } else if (req.method === "DELETE") { 370 | const user = await getUser(req); 371 | let models: (Model | null)[] = []; 372 | if (getAnyQueryValueOfKey(req, "massive") === "true") { 373 | const { 374 | err, 375 | fields: { modelList }, 376 | } = await getFormidableFileFromReq(req); 377 | if (err) { 378 | res.status(500).json({ ok: false, message: "Failed parsing request." }); 379 | return; 380 | } 381 | models = modelList 382 | ? await prismaClient.model.findMany({ 383 | where: { 384 | id: { in: modelList }, 385 | }, 386 | }) 387 | : []; 388 | } else { 389 | const modelId = getAnyQueryValueOfKey(req, "id"); 390 | if (!modelId) { 391 | res.status(400).json({ error: "model id query not found" }); 392 | return; 393 | } 394 | models = [ 395 | await prismaClient.model.findUnique({ 396 | where: { 397 | id: modelId, 398 | }, 399 | }), 400 | ]; 401 | } 402 | const results = await Promise.allSettled( 403 | models.map(async (model) => { 404 | if (!model) { 405 | throw "Couldn't find model by id."; 406 | } 407 | if (!user) { 408 | throw "Couldn't find user."; 409 | } 410 | await deleteModelFromDBAndS3(model, user); 411 | return "Success!"; 412 | }) 413 | ); 414 | res.json(results); 415 | } 416 | } 417 | 418 | // FOR RESPONE TO GET 419 | 420 | const makeModelInfo: (model: any) => ModelInfo = (model) => { 421 | const thumbnailSrc = model.thumbnail 422 | ? `/getResource/models/${model.id}/${model.thumbnail}` 423 | : ""; 424 | const usdzSrc = model.modelUsdz 425 | ? `/getResource/models/${model.id}/${model.modelUsdz}` 426 | : ""; 427 | return { 428 | ...model, 429 | modelSrc: `/getResource/models/${model.id}/${model.modelFile}`, 430 | thumbnailSrc, 431 | usdzSrc, 432 | }; 433 | }; 434 | 435 | const makeModelInfos: (models: Model[]) => ModelInfo[] = (models) => 436 | models.map((model) => makeModelInfo(model)); 437 | 438 | // FOR RESPONE TO POST 439 | 440 | export const getFormidableFileFromReq = async ( 441 | req: NextApiRequest, 442 | options?: formidable.Options 443 | ) => { 444 | return await new Promise((res, rej) => { 445 | const form = formidable( 446 | options ?? { 447 | multiples: true, 448 | maxFileSize: 150 << 20, // 100MB for zip file 449 | keepExtensions: true, 450 | } 451 | ); 452 | form.parse(req, (err: Error, fields, files) => { 453 | if (err) { 454 | return rej(err); 455 | } 456 | return res({ err, fields, files }); 457 | }); 458 | }); 459 | }; 460 | 461 | /** 462 | * FOR RESPONE TO DELETE 463 | * Check user authenication then handle delete request. 464 | */ 465 | const deleteModelFromDBAndS3 = async (model: Model, user: User) => { 466 | if ( 467 | !hasRight( 468 | { 469 | method: "delete", 470 | theme: "model", 471 | }, 472 | user, 473 | model 474 | ) 475 | ) { 476 | throw `User <${user.name}> doesn't have right to delete model <${model.name}>.`; 477 | } 478 | await deleteS3Files(model.id); 479 | await prismaClient.model.delete({ 480 | where: { 481 | id: model.id, 482 | }, 483 | }); 484 | }; 485 | -------------------------------------------------------------------------------- /pages/api/users/index.ts: -------------------------------------------------------------------------------- 1 | import { getUser } from "@libs/server/prismaClient"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | const allowedMethod = ["GET", "DELETE"]; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | const user = await getUser(req); 11 | switch (req.method) { 12 | case "GET": { 13 | res.json(user); 14 | break; 15 | } 16 | case "DELETE": { 17 | res.status(405).json({ ok: false }); 18 | break; 19 | } 20 | default: { 21 | res.status(500).json({ ok: false }); 22 | break; 23 | } 24 | } 25 | return; 26 | } 27 | -------------------------------------------------------------------------------- /pages/dev/config.tsx: -------------------------------------------------------------------------------- 1 | import Wrapper from "@components/Wrapper"; 2 | import type { NextPage } from "next"; 3 | 4 | const PageConfing: NextPage = () => { 5 | return ( 6 | 7 | { 8 | // gray button with border 9 |
10 | 16 | 17 | 아직 설정된 text나 config 값이 없는 경우 초기 값을 지정합니다 18 | 19 |
20 | } 21 |
22 | ); 23 | }; 24 | 25 | export default PageConfing; 26 | -------------------------------------------------------------------------------- /pages/dev/index.tsx: -------------------------------------------------------------------------------- 1 | import Wrapper from "@components/Wrapper"; 2 | import type { NextPage } from "next"; 3 | import Link from "next/link"; 4 | 5 | const DevIndexPage: NextPage = () => { 6 | return ( 7 | 8 |
9 | 10 |
11 | 모델 여러개 업로드 12 |
13 | 14 | 15 |
16 | 모델 여러개 삭제 17 |
18 | 19 | 20 |
21 | 홈페이지 설정 22 |
23 | 24 | 25 |
26 | 저장소, DB 차이 확인 27 |
28 | 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default DevIndexPage; 35 | -------------------------------------------------------------------------------- /pages/dev/models/delete.tsx: -------------------------------------------------------------------------------- 1 | import Wrapper from "@components/Wrapper"; 2 | import { useModelInfos } from "@libs/client/AccessDB"; 3 | import { AddUnit } from "@libs/client/Util"; 4 | import path from "path"; 5 | import { SyntheticEvent } from "react"; 6 | import { useForm } from "react-hook-form"; 7 | import { useSWRConfig } from "swr"; 8 | import { AllSettleResult, SuccessCounter } from "./upload"; 9 | 10 | const DeleteModelsPage = () => { 11 | const models = useModelInfos(); 12 | const { mutate: componentMutate } = useSWRConfig(); 13 | const { 14 | register, 15 | handleSubmit, 16 | formState: { isSubmitting }, 17 | setValue, 18 | watch, 19 | } = useForm(); 20 | 21 | return ( 22 | 23 |
25 | onValid(form, () => { 26 | componentMutate("/api/models"); 27 | }) 28 | )} 29 | > 30 |
31 | 32 | 33 | 34 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {models.data?.map((model, i) => ( 60 | 61 | 69 | 72 | 75 | 78 | 81 | 84 | 87 | 90 | 91 | ))} 92 | 93 |
35 | Check 36 | ) => { 40 | const boxes = watch(); 41 | const bool = e.currentTarget.checked; 42 | Object.entries(boxes).forEach(([key, val]) => { 43 | setValue(key, bool); 44 | }); 45 | }} 46 | className="flex m-auto h-5 w-5" 47 | > 48 | NumberNameusdzgltfUploadAtSizeUploader
62 | 68 | 70 | {i} 71 | 73 | {model.name} 74 | 76 | {path.basename(model.usdzSrc)} 77 | 79 | {path.basename(model.modelSrc)} 80 | 82 | {new Date(model.createdAt).toUTCString()} 83 | 85 | {AddUnit(model.modelSize) + "B"} 86 | 88 | {model.uploader?.name ?? ""} 89 |
94 |
95 |
96 | 선택된 모델 개수 :{" "} 97 | {Object.entries(watch()).filter(([key, val]) => val === true).length} 98 |
99 | 126 |
127 |
128 | ); 129 | }; 130 | 131 | export default DeleteModelsPage; 132 | 133 | async function onValid(form: object, refresh: () => void) { 134 | const formBody = new FormData(); 135 | const targetModels = Object.entries(form) 136 | .filter(([id, isChecked]) => isChecked) 137 | .forEach((list) => formBody.append("modelList", list[0])); 138 | 139 | const res: AllSettleResult[] = await fetch("/api/models?massive=true", { 140 | method: "DELETE", 141 | body: formBody, 142 | }).then((res) => res.json()); 143 | const count = SuccessCounter(res); 144 | alert( 145 | `삭제요청의 수 : ${count.total}\n 성공한 삭제 수 : ${count.successCnt}` 146 | ); 147 | count.successCnt === count.total 148 | ? null 149 | : alert( 150 | res 151 | .filter((report) => report.status === "rejected") 152 | .reduce((prev, cur) => prev + `${cur.reason}\n`, "실패한 이유 \n") 153 | ); 154 | refresh(); 155 | } 156 | 157 | function UncheckMainboxIfslaveUnchecked(e: SyntheticEvent) { 158 | const masterCheckbox = document.getElementById( 159 | "main-checkbox" 160 | ) as HTMLInputElement; 161 | if (e.currentTarget.checked === false) { 162 | masterCheckbox.checked = false; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /pages/dev/models/diff.tsx: -------------------------------------------------------------------------------- 1 | import { useModelInfos } from "@libs/client/AccessDB"; 2 | import { deleteS3Files, getSavedModelList } from "@libs/server/s3client"; 3 | import type { NextPage } from "next"; 4 | import { useEffect, useState } from "react"; 5 | 6 | const Diff: NextPage = () => { 7 | const dbModels = useModelInfos(); 8 | const [s3Set, setS3Set] = useState(new Set()); 9 | useEffect(() => { 10 | const func = async () => { 11 | const res: Set = (await getSavedModelList()) ?? new Set(); 12 | setS3Set(res); 13 | }; 14 | if (s3Set.size !== 0) { 15 | return; 16 | } 17 | func(); 18 | }); 19 | if (!dbModels.data) { 20 | return Loading; 21 | } 22 | const dbSet: Set = 23 | dbModels.data?.reduce((prev, cur) => { 24 | prev.add(cur.id); 25 | return prev; 26 | }, new Set()) ?? new Set(); 27 | const dbOnly = [...dbSet].filter((e) => !s3Set.has(e)); 28 | const s3Only = [...s3Set].filter((e) => !dbSet.has(e)); 29 | console.log("db Only", dbOnly); 30 | console.log("s3 Only", s3Only); 31 | return ( 32 |
33 | {/* 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
numuuid
11
*/} 47 | 55 |
56 | ); 57 | }; 58 | 59 | export default Diff; 60 | 61 | async function deleteS3Models(uuids: string[]) { 62 | const faileds: string[] = []; 63 | const successes: string[] = []; 64 | uuids.forEach(async (uuid) => { 65 | await deleteS3Files(uuid) 66 | .catch((e) => faileds.push(uuid)) 67 | .then((res) => successes.push(uuid)); 68 | }); 69 | console.log("faileds", faileds); 70 | console.log("successes", successes); 71 | return; 72 | } 73 | -------------------------------------------------------------------------------- /pages/dev/models/upload.tsx: -------------------------------------------------------------------------------- 1 | import Wrapper from "@components/Wrapper"; 2 | import { useSession } from "next-auth/react"; 3 | import { useRouter } from "next/router"; 4 | import { useState } from "react"; 5 | import Dropzone from "react-dropzone"; 6 | import { FieldValues } from "react-hook-form"; 7 | 8 | const Upload = () => { 9 | const [files, setFiles] = useState([]); 10 | const router = useRouter(); 11 | const session = useSession(); 12 | const [isSubmitting, setIsSubmitting] = useState(false); 13 | 14 | if (session.status === "loading") { 15 | return null; 16 | } 17 | 18 | if (session.status === "unauthenticated") { 19 | router.push("/models"); 20 | } 21 | 22 | const onClick = async (form: FieldValues) => { 23 | if (files.length === 0) { 24 | return alert("파일 업로드해주시길 바랍니다."); 25 | } 26 | 27 | setIsSubmitting(true); 28 | const formData = new FormData(); 29 | files.forEach((file) => { 30 | formData.append("file", file); 31 | }); 32 | 33 | const res = await fetch("/api/models?massiveUpload=true", { 34 | method: "POST", 35 | body: formData, 36 | }) 37 | .then((res) => res.json()) 38 | .then((json) => SuccessCounter(json.results)); 39 | setIsSubmitting(false); 40 | alert( 41 | `업로드 파일 수 : ${res.total}\n 성공한 업로드 수 : ${res.successCnt}` 42 | ); 43 | setFiles([]); 44 | }; 45 | 46 | return ( 47 | 48 | { 51 | setFiles([...files, ...acceptedFiles]); 52 | }} 53 | > 54 | {({ getRootProps, getInputProps }) => ( 55 |
59 | {files.length > 0 ? ( 60 |
{ 63 | setFiles([]); 64 | e.stopPropagation(); 65 | }} 66 | > 67 | reset 68 |
69 | ) : null} 70 | 71 |
72 | {files.length === 0 ? ( 73 | <> 74 | 82 | 86 | 90 | 94 | 98 | 99 |
100 |

101 | 파일 업로드 102 |

103 |

104 | 업로드할 .zip 파일을 선택해주세요. 여러개도 선택 105 | 가능합니다. 106 |

107 |
108 | 109 | ) : ( 110 | 111 | 112 | 113 | 116 | 119 | 120 | 121 | 122 | {files?.map((files, i) => { 123 | const size = files.size; 124 | const kbSize = Math.floor(size / 1000); 125 | const mbSize = Math.floor(kbSize / 1000); 126 | 127 | return ( 128 | 129 | 132 | 139 | 140 | ); 141 | })} 142 | 143 |
114 | File name 115 | 117 | Size 118 |
130 | {files.name} 131 | 133 | {size < 1000 134 | ? size + "bite" 135 | : kbSize > 1000 136 | ? mbSize + "mb" 137 | : kbSize + "kb"} 138 |
144 | )} 145 |
146 |
147 | )} 148 |
149 | 177 |
178 | ); 179 | }; 180 | 181 | export default Upload; 182 | 183 | export function SuccessCounter(results: AllSettleResult[]) { 184 | const successCnt = results 185 | .map((res) => (res.status === "fulfilled" ? 1 : 0)) 186 | .reduce((prev: number, cur: number) => prev + cur, 0); 187 | return { total: results.length, successCnt }; 188 | } 189 | 190 | export type AllSettleResult = { 191 | status: "fulfilled" | "rejected"; 192 | reason?: string; 193 | [key: string]: any; 194 | }; 195 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import { useRouter } from "next/router"; 3 | import { useEffect } from "react"; 4 | 5 | const Home: NextPage = () => { 6 | const router = useRouter(); 7 | useEffect(() => { 8 | router.replace("models"); 9 | }, []); 10 | 11 | return root page; 12 | }; 13 | 14 | export default Home; 15 | -------------------------------------------------------------------------------- /pages/models/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import ModelModal from "@components/ModelModal"; 2 | import { useModelInfo, useUser } from "@libs/client/AccessDB"; 3 | import type { NextPage } from "next"; 4 | import { useRouter } from "next/router"; 5 | 6 | interface ModelElemet extends Element { 7 | showPoster: () => void; 8 | dismissPoster: () => void; 9 | } 10 | 11 | const ModelPage: NextPage = () => { 12 | const router = useRouter(); 13 | const modelId = (router.query.id as string) ?? ""; 14 | const modelInfo = useModelInfo(modelId); 15 | const user = useUser(); 16 | 17 | if (modelInfo.error || user.error) { 18 | router.push("/"); 19 | return null; 20 | } 21 | if (!modelInfo.data) { 22 | return null; 23 | } 24 | 25 | return ( 26 | {}} 28 | modelId={modelId} 29 | pageMode={true} 30 | > 31 | ); 32 | }; 33 | 34 | export const increaseView = (modelId: string) => { 35 | fetch(`/api/models/${modelId}?view=true`, { 36 | method: "POST", 37 | }); 38 | }; 39 | 40 | export default ModelPage; 41 | -------------------------------------------------------------------------------- /pages/models/[id]/three.tsx: -------------------------------------------------------------------------------- 1 | import ModelInfo from "@components/ModelInfo"; 2 | import ThreeViewer from "@components/ThreeViewer"; 3 | import Wrapper from "@components/Wrapper"; 4 | import { useModelInfo, useUser } from "@libs/client/AccessDB"; 5 | import { hasRight } from "@libs/server/Authorization"; 6 | import type { NextPage } from "next"; 7 | import dynamic from "next/dynamic"; 8 | import { NextRouter, useRouter } from "next/router"; 9 | import { useEffect, useRef, useState } from "react"; 10 | 11 | const Model = dynamic(() => import("@components/Model"), { ssr: false }); 12 | 13 | interface ModelElemet extends Element { 14 | showPoster: () => void; 15 | dismissPoster: () => void; 16 | } 17 | 18 | const ModelPage: NextPage = () => { 19 | const router = useRouter(); 20 | const modelId = (router.query.id as string) ?? ""; 21 | const modelInfo = useModelInfo(modelId); 22 | const user = useUser(); 23 | const timer = useRef(Date.now()); 24 | const [modelViewer, setModelViewer] = useState(); 25 | const [isLogShown, setIsLogShown] = useState(false); 26 | const [logs, setLogs] = useState([]); 27 | useEffect(() => { 28 | // hook modelviewer elemet when loading is complete. 29 | const checker = setInterval(() => { 30 | const modelElem = document.querySelector("#modelViewer"); 31 | if (modelElem) { 32 | setModelViewer(modelElem as ModelElemet); 33 | clearInterval(checker); 34 | return; 35 | } 36 | }, 50); 37 | return () => { 38 | if (!modelViewer) { 39 | clearInterval(checker); 40 | } 41 | }; 42 | }, [modelViewer]); 43 | 44 | useEffect(() => { 45 | // when modelViewer founded 46 | const callback = (e: any) => { 47 | if (e.detail?.totalProgress === 1) { 48 | const spentTime = (Date.now() - timer.current) / 1000; 49 | 50 | setLogs((log) => 51 | log.concat(` : Loading spent ${spentTime} sec.`) 52 | ); 53 | } 54 | }; 55 | if (modelViewer) { 56 | modelViewer.addEventListener("progress", callback); 57 | } 58 | return () => { 59 | modelViewer?.removeEventListener("progress", callback); 60 | }; 61 | }, [modelViewer]); 62 | 63 | if (modelInfo.error || user.error) { 64 | router.push("/"); 65 | return null; 66 | } 67 | if (!modelInfo.data) { 68 | return null; 69 | } 70 | 71 | return ( 72 | 73 | 77 | 78 | {!modelInfo.loading ? modelInfo.data.name : ""} 79 | 80 |
81 |
82 | {isLogShown ? ( 83 |
84 | {logs.map((log, index) => ( 85 | 86 | {log} 87 | 88 | ))} 89 |
90 | ) : null} 91 | {!modelInfo.loading ? ( 92 | 93 | ) : ( 94 | "Loading..." 95 | )} 96 | 104 |
105 |
106 | {hasRight( 107 | { method: "read", theme: "model" }, 108 | user.data, 109 | modelInfo.data 110 | ) ? ( 111 | 119 | ) : null} 120 | {hasRight( 121 | { method: "update", theme: "model" }, 122 | user.data, 123 | modelInfo.data 124 | ) ? ( 125 | 131 | ) : null} 132 | {hasRight( 133 | { method: "delete", theme: "model" }, 134 | user.data, 135 | modelInfo.data 136 | ) ? ( 137 | 143 | ) : null} 144 |
145 | 146 |
147 | 148 | 149 | {!modelInfo.loading ? `Category > ${modelInfo.data.category}` : ""} 150 | 151 | 152 | {!modelInfo.loading ? modelInfo.data.description : ""} 153 | 154 |
155 | ); 156 | }; 157 | 158 | const callDeleteAPI = (id: string, router: NextRouter) => { 159 | fetch(`/api/models/${id}`, { 160 | method: "DELETE", 161 | }) 162 | .then((res) => { 163 | if (!res.ok) { 164 | throw res.json(); 165 | } 166 | router.push(`/models`); 167 | }) 168 | .catch((error) => { 169 | alert(`error : ${error.message}`); 170 | }); 171 | }; 172 | 173 | export default ModelPage; 174 | -------------------------------------------------------------------------------- /pages/models/[id]/update.tsx: -------------------------------------------------------------------------------- 1 | import ErrorDiv from "@components/ErrorDiv"; 2 | import Wrapper from "@components/Wrapper"; 3 | import { UploadForm } from "@customTypes/model"; 4 | import { useModelInfo, useUser } from "@libs/client/AccessDB"; 5 | import { hasRight } from "@libs/server/Authorization"; 6 | import type { NextPage } from "next"; 7 | import { useRouter } from "next/router"; 8 | import { FieldValues, useForm } from "react-hook-form"; 9 | 10 | const UpdatePage: NextPage = () => { 11 | const router = useRouter(); 12 | const modelId = router.query.id as string; 13 | const user = useUser(); 14 | const model = useModelInfo(modelId); 15 | const { register, handleSubmit, formState } = useForm({ 16 | defaultValues: { 17 | name: model.data?.name, 18 | category: model.data?.category, 19 | description: model.data?.description, 20 | }, 21 | }); 22 | const loading = user.loading || model.loading; 23 | if (loading) return null; 24 | if (!hasRight({ method: "update", theme: "model" }, user.data, model.data)) { 25 | router.push(`/models/${modelId}`); 26 | } 27 | const onValid = async (form: FieldValues) => { 28 | const res = await fetch(`/api/models/${modelId}`, { 29 | method: "PATCH", 30 | body: JSON.stringify(form), 31 | }).then((res) => res.json()); 32 | if (!res.ok) { 33 | alert(`업데이트에 실패하였습니다. ${res.message ?? ""}`); 34 | return "error"; 35 | } 36 | 37 | router.push(`/models/${modelId}`); 38 | }; 39 | return ( 40 | 41 |
42 |
43 | Thumbnail 44 |
45 |
46 |
47 |
48 | 51 | 58 | 59 |
60 |
61 | 67 | 72 |
73 |
74 | 77 | 102 | 103 |
104 |
105 | 108 | 113 |
114 |
115 | 123 | 150 |
151 |
152 |
153 |
154 |
155 | ); 156 | }; 157 | 158 | export default UpdatePage; 159 | -------------------------------------------------------------------------------- /pages/models/index.tsx: -------------------------------------------------------------------------------- 1 | import MainPageShowcase from "@components/MainPageShowcase"; 2 | import SearchBar from "@components/Search"; 3 | import Thumbnails from "@components/Thumbnails"; 4 | import Wrapper from "@components/Wrapper"; 5 | import { ModelInfo } from "@customTypes/model"; 6 | import { useModelInfos, useSiteConfig } from "@libs/client/AccessDB"; 7 | import type { NextPage } from "next"; 8 | import { useSession } from "next-auth/react"; 9 | import { useState } from "react"; 10 | 11 | export interface ModelInfos { 12 | loading: boolean; 13 | data: ModelInfo[] | undefined; 14 | error: any; 15 | } 16 | 17 | const ModelsMainPage: NextPage = () => { 18 | const [models, setModels] = useState(); 19 | const modelsInfos = useModelInfos(); 20 | const session = useSession(); 21 | const devMode = 22 | session.data?.role === "ADMIN" || session.data?.role === "DEVELOPER"; 23 | 24 | const { data: { config } = {} } = useSiteConfig(); 25 | const { data: [mainModel] = [null] } = useModelInfos({ 26 | id: config?.showCaseModelId, 27 | }); 28 | return ( 29 | 30 | {config?.isShowcaseVisible === "true" ? ( 31 | 32 | ) : null} 33 | 34 | {models?.error ? ( 35 |
36 |
37 | {models.error} 38 |
39 |
40 | ) : ( 41 | 46 | )} 47 |
48 | ); 49 | }; 50 | 51 | export default ModelsMainPage; 52 | -------------------------------------------------------------------------------- /pages/models/upload.tsx: -------------------------------------------------------------------------------- 1 | import ErrorDiv from "@components/ErrorDiv"; 2 | import Wrapper from "@components/Wrapper"; 3 | import { UploadForm } from "@customTypes/model"; 4 | import { useUploadable } from "@libs/client/AccessDB"; 5 | import { useSession } from "next-auth/react"; 6 | import { useRouter } from "next/router"; 7 | import { useEffect, useState } from "react"; 8 | import Dropzone from "react-dropzone"; 9 | import { useForm } from "react-hook-form"; 10 | 11 | const Upload = () => { 12 | const [files, setFiles] = useState([]); 13 | const { 14 | register, 15 | setValue: setFormValue, 16 | watch: getFormValue, 17 | handleSubmit, 18 | reset: formReset, 19 | formState, 20 | } = useForm(); 21 | const router = useRouter(); 22 | const session = useSession(); 23 | const fields = getFormValue(); 24 | const isSubmitting = formState.isSubmitting; 25 | const formKeyName = "uploadForm"; 26 | const isUploadable = useUploadable(); 27 | 28 | useEffect(() => { 29 | // set uploadForm if upload form data remained 30 | const prev = JSON.parse(localStorage.getItem(formKeyName) ?? "{}"); 31 | for (const key in prev) { 32 | setFormValue(key as any, prev[key]); 33 | } 34 | }, [setFormValue]); 35 | 36 | useEffect(() => { 37 | // remember input to local storage 38 | localStorage.setItem(formKeyName, JSON.stringify(fields)); 39 | }, [fields]); 40 | 41 | if (session.status === "loading") { 42 | return null; 43 | } 44 | if (isUploadable === false) { 45 | router.replace("/models"); 46 | } 47 | 48 | const onValid = async (form: UploadForm) => { 49 | if (files.length === 0) { 50 | return alert("파일 업로드해주시길 바랍니다."); 51 | } 52 | 53 | const formData = new FormData(); 54 | files.forEach((file) => { 55 | formData.append("file", file); 56 | }); 57 | 58 | formData.append("form", JSON.stringify(form)); 59 | 60 | const res = await fetch("/api/models", { 61 | method: "POST", 62 | body: formData, 63 | }).then((res) => res.json()); 64 | const resStatus: "fulfilled" | "rejected" = res.results[0].status; 65 | if (resStatus === "rejected") { 66 | const ans = await res.message; 67 | alert(`업로드에 실패하였습니다.${ans ? "\n" + ans : ""}`); 68 | return "error"; 69 | } 70 | alert("파일이 업로드 되었습니다."); 71 | localStorage.removeItem(formKeyName); 72 | formReset(); 73 | router.push("/models"); 74 | }; 75 | 76 | return ( 77 | 78 |
79 | { 83 | setFiles(acceptedFiles); 84 | acceptedFiles.forEach((file) => { 85 | const zipName = file.name.split(".").slice(0, -1).join("."); 86 | setFormValue("name", zipName); 87 | }); 88 | }} 89 | > 90 | {({ getRootProps, getInputProps }) => ( 91 |
95 | 96 | {files.length === 0 ? ( 97 |
98 | 106 | 110 | 114 | 118 | 122 | 123 |
124 |

125 | 파일 업로드 126 |

127 |

128 | 업로드할 .zip 파일을 끌어다놓으세요. 129 |

130 |
131 |
132 |

133 | 압축파일은{" "} 134 | 135 | ./scene.gltf 와 ./thumbnail.png 파일 136 | {" "} 137 | 을 포함해야 합니다. 138 |

139 |
140 |
141 | ) : ( 142 |
143 |
148 |

name

149 |

size

150 |
151 | {files?.map((files, i) => { 152 | const size = files.size; 153 | const kbSize = Math.floor(size / 1000); 154 | const mbSize = Math.floor(kbSize / 1000); 155 | 156 | return ( 157 |
163 |

{files.name}

164 |

165 | {size < 1000 166 | ? size + "bite" 167 | : kbSize > 1000 168 | ? mbSize + "mb" 169 | : kbSize + "kb"} 170 |

171 |
172 | ); 173 | })} 174 |
175 | )} 176 |
177 | )} 178 |
179 |
180 |
181 | Thumbnail 182 |
183 |
184 |
185 | 188 | 195 | 196 |
197 |
198 | 204 | 209 |
210 |
211 | 214 | 239 | 240 |
241 |
242 | 245 | 250 |
251 | 278 |
279 |
280 |
281 |
282 | ); 283 | }; 284 | 285 | export default Upload; 286 | -------------------------------------------------------------------------------- /pages/users/[id]/index.tsx: -------------------------------------------------------------------------------- 1 | import Thumbnails from "@components/Thumbnails"; 2 | import Wrapper from "@components/Wrapper"; 3 | import { useModelInfos, useUser } from "@libs/client/AccessDB"; 4 | import { useSession } from "next-auth/react"; 5 | import { useRouter } from "next/router"; 6 | 7 | function UserPage() { 8 | const user = useUser(); 9 | const router = useRouter(); 10 | const session = useSession(); 11 | const modelInfos = useModelInfos({ uploader: user.data?.id }); 12 | if (session.status === "unauthenticated") { 13 | router.replace("/models"); 14 | } 15 | if (user.loading || modelInfos.loading) return; 16 | return ( 17 | 18 |

내가 올린 모델들

19 |
20 | 21 | 22 | ); 23 | } 24 | 25 | export default UserPage; 26 | -------------------------------------------------------------------------------- /pages/users/index.tsx: -------------------------------------------------------------------------------- 1 | import { useUser } from "@libs/client/AccessDB"; 2 | import { useSession } from "next-auth/react"; 3 | import { useRouter } from "next/router"; 4 | 5 | function UsersPage() { 6 | const router = useRouter(); 7 | const session = useSession(); 8 | const user = useUser(); 9 | const onLoading = 10 | session.status === "loading" || user.loading || !router.isReady; 11 | if (onLoading) return; 12 | if (session.status === "unauthenticated" || !user.data) { 13 | router.replace("/models"); 14 | return; 15 | } 16 | router.replace(`/users/${user.data.id}`); 17 | return; 18 | } 19 | 20 | export default UsersPage; 21 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = env("DATABASE_URL") 4 | referentialIntegrity = "prisma" 5 | } 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | previewFeatures = ["referentialIntegrity"] 10 | } 11 | 12 | model Account { 13 | id String @id @default(cuid()) 14 | userId String 15 | type String 16 | provider String 17 | providerAccountId String 18 | refresh_token String? @db.Text 19 | access_token String? @db.Text 20 | expires_at Int? 21 | token_type String? 22 | scope String? 23 | id_token String? @db.Text 24 | session_state String? 25 | createdAt DateTime @default(now()) 26 | updatedAt DateTime @default(now()) @updatedAt() 27 | 28 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 29 | 30 | @@unique([provider, providerAccountId]) 31 | } 32 | 33 | model Session { 34 | id String @id @default(cuid()) 35 | sessionToken String @unique 36 | userId String 37 | expires DateTime 38 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 39 | } 40 | 41 | model User { 42 | id String @id @default(cuid()) 43 | name String? 44 | email String @unique 45 | emailVerified DateTime? 46 | image String? 47 | accounts Account[] 48 | sessions Session[] 49 | role Role @default(USER) 50 | createdAt DateTime @default(now()) 51 | updatedAt DateTime @default(now()) @updatedAt() 52 | uploadModels Model[] 53 | Log Log[] 54 | Comment Comment[] 55 | } 56 | 57 | model VerificationToken { 58 | identifier String 59 | token String @unique 60 | expires DateTime 61 | 62 | @@unique([identifier, token]) 63 | } 64 | 65 | model Model { 66 | id String @id @default(uuid()) 67 | name String 68 | uploader User @relation(fields: [userId], references: [id]) 69 | createdAt DateTime @default(now()) 70 | updatedAt DateTime @default(now()) @updatedAt() 71 | userId String 72 | thumbnail String? @default("") 73 | modelFile String @default("scene.gltf") 74 | modelUsdz String? 75 | category ModelCategory @default(MISC) 76 | tags Json? 77 | description String? @db.Text() 78 | modelVertex BigInt? @default(0) 79 | modelTriangle BigInt? @default(0) 80 | modelSize BigInt? @default(0) 81 | usdzSize BigInt? @default(0) 82 | zipSize BigInt? @default(0) 83 | viewed Int @default(0) 84 | blinded Boolean @default(false) 85 | Comment Comment[] 86 | } 87 | 88 | model Comment { 89 | id Int @id @default(autoincrement()) 90 | commenter User @relation(fields: [userId], references: [id], onDelete: Cascade) 91 | createdAt DateTime @default(now()) 92 | updatedAt DateTime @default(now()) @updatedAt() 93 | text String 94 | modelCommented Model @relation(fields: [modelId], references: [id], onDelete: Cascade) 95 | userId String 96 | modelId String 97 | } 98 | 99 | model SiteText { 100 | id String @id 101 | text String @default("default") 102 | } 103 | 104 | model SiteConfig { 105 | id String @id 106 | value String 107 | } 108 | 109 | enum Role { 110 | UNAUTHENTICATED 111 | USER 112 | ADMIN 113 | DEVELOPER 114 | } 115 | 116 | enum ModelCategory { 117 | MISC 118 | FURNITURE 119 | ARCHITECTURE 120 | ANIMALS 121 | FOOD 122 | CHARACTERS 123 | NATURE 124 | VEHICLES 125 | SCENES 126 | ACCESSORIES 127 | HEALTH 128 | INSTRUMENTS 129 | PLANTS 130 | WEAPONS 131 | TECHNOLOGY 132 | } 133 | 134 | model Log { 135 | id BigInt @id @default(autoincrement()) 136 | userId String 137 | action Action 138 | createdAt DateTime @default(now()) 139 | User User @relation(fields: [userId], references: [id]) 140 | } 141 | 142 | enum Action { 143 | MODEL_DOWNLOAD 144 | MODEL_UPLOAD 145 | } 146 | -------------------------------------------------------------------------------- /public/MaruBuri-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/MaruBuri-Regular.woff -------------------------------------------------------------------------------- /public/closeBtn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/closeBtn.png -------------------------------------------------------------------------------- /public/comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/comment.png -------------------------------------------------------------------------------- /public/cube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/cube.png -------------------------------------------------------------------------------- /public/list_gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/list_gray.png -------------------------------------------------------------------------------- /public/open_license.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/open_license.jpg -------------------------------------------------------------------------------- /public/searchIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/searchIcon.png -------------------------------------------------------------------------------- /public/showcaseBanner-blackToWhite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/showcaseBanner-blackToWhite.png -------------------------------------------------------------------------------- /public/upload1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/upload1.png -------------------------------------------------------------------------------- /public/views.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParallelSpaceInc/poly-web/ee1a4b83d4d5caac0a071303c997a7422acfd00a/public/views.png -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: 'MaruBuri-Regular'; 7 | src: url('/MaruBuri-Regular.woff') format('woff'); 8 | } -------------------------------------------------------------------------------- /supabase/client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | const supabaseURL: string = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; 4 | const supabaseServerKey: string = process.env.NEXT_PUBLIC_SUPABASE_KEY || ''; 5 | 6 | export const supabase = createClient(supabaseURL, supabaseServerKey); 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require("tailwindcss/defaultTheme"); 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx}", 5 | "./components/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | fontFamily: { 10 | 'MaruBuri': ['MaruBuri-Regular'] 11 | }, 12 | colors: { 13 | "google-blue": "#4285F4", 14 | "header-gray": "#444444", 15 | }, 16 | transitionProperty: { 17 | ...defaultTheme, 18 | width: "width", 19 | height: "height", 20 | }, 21 | }, 22 | }, 23 | plugins: [], 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@components/*": ["components/*"], 20 | "@api/*": ["pages/api/*"], 21 | "@styles/*": ["styles/*"], 22 | "@supabase/*": ["supabase/*"], 23 | "@libs/*": ["libs/*"], 24 | "@customTypes/*": ["customTypes/*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 28 | "exclude": ["node_modules"] 29 | } 30 | --------------------------------------------------------------------------------