Showing
48 changed files
with
1297 additions
and
106 deletions
backend/.gitignore
0 → 100644
... | @@ -22,7 +22,7 @@ from django.conf import settings | ... | @@ -22,7 +22,7 @@ from django.conf import settings |
22 | import jwt | 22 | import jwt |
23 | from django.http import HttpResponse, JsonResponse | 23 | from django.http import HttpResponse, JsonResponse |
24 | from khudrive.settings import AWS_SESSION_TOKEN, AWS_SECRET_ACCESS_KEY, AWS_ACCESS_KEY_ID, AWS_REGION, \ | 24 | from khudrive.settings import AWS_SESSION_TOKEN, AWS_SECRET_ACCESS_KEY, AWS_ACCESS_KEY_ID, AWS_REGION, \ |
25 | - AWS_STORAGE_BUCKET_NAME | 25 | + AWS_STORAGE_BUCKET_NAME, AWS_ENDPOINT_URL |
26 | 26 | ||
27 | 27 | ||
28 | class UserViewSet(viewsets.ModelViewSet): | 28 | class UserViewSet(viewsets.ModelViewSet): |
... | @@ -51,6 +51,8 @@ class UserViewSet(viewsets.ModelViewSet): | ... | @@ -51,6 +51,8 @@ class UserViewSet(viewsets.ModelViewSet): |
51 | root = Item(is_folder=True, name="root", file_type="folder", path="", user_id=user.int_id, size=0, | 51 | root = Item(is_folder=True, name="root", file_type="folder", path="", user_id=user.int_id, size=0, |
52 | status=True) | 52 | status=True) |
53 | root.save() | 53 | root.save() |
54 | + user.root_folder = root.item_id | ||
55 | + user.save() | ||
54 | return Response({ | 56 | return Response({ |
55 | 'message': 'user created', | 57 | 'message': 'user created', |
56 | 'int_id': user.int_id, | 58 | 'int_id': user.int_id, |
... | @@ -94,7 +96,15 @@ class UserViewSet(viewsets.ModelViewSet): | ... | @@ -94,7 +96,15 @@ class UserViewSet(viewsets.ModelViewSet): |
94 | exp = jwt.decode(access, settings.SECRET_KEY, algorithm='HS256')['exp'] | 96 | exp = jwt.decode(access, settings.SECRET_KEY, algorithm='HS256')['exp'] |
95 | token = {'access': access, | 97 | token = {'access': access, |
96 | 'refresh': refresh, | 98 | 'refresh': refresh, |
97 | - 'exp': exp} | 99 | + 'exp': exp, |
100 | + 'user': { | ||
101 | + 'int_id': user.int_id, | ||
102 | + 'user_id': user.user_id, | ||
103 | + 'name': user.name, | ||
104 | + 'total_size': user.total_size, | ||
105 | + 'current_size': user.current_size, | ||
106 | + 'root_folder': user.root_folder | ||
107 | + }} | ||
98 | return JsonResponse( | 108 | return JsonResponse( |
99 | token, | 109 | token, |
100 | status=status.HTTP_200_OK, | 110 | status=status.HTTP_200_OK, |
... | @@ -173,11 +183,15 @@ class ItemViewSet(viewsets.ViewSet): | ... | @@ -173,11 +183,15 @@ class ItemViewSet(viewsets.ViewSet): |
173 | # url: items/11/ | 183 | # url: items/11/ |
174 | # 마지막 slash도 써주어야함 | 184 | # 마지막 slash도 써주어야함 |
175 | def get(self, request, pk): | 185 | def get(self, request, pk): |
176 | - s3 = boto3.client('s3', | 186 | + s3 = boto3.client( |
177 | - aws_access_key_id=AWS_ACCESS_KEY_ID, | 187 | + 's3', |
178 | - aws_secret_access_key=AWS_SECRET_ACCESS_KEY, | 188 | + region_name=AWS_REGION, |
179 | - aws_session_token=AWS_SESSION_TOKEN, | 189 | + aws_access_key_id=AWS_ACCESS_KEY_ID, |
180 | - config=Config(signature_version='s3v4')) | 190 | + aws_secret_access_key=AWS_SECRET_ACCESS_KEY, |
191 | + aws_session_token=AWS_SESSION_TOKEN, | ||
192 | + endpoint_url=AWS_ENDPOINT_URL or None, | ||
193 | + config=Config(s3={'addressing_style': 'path'}) | ||
194 | + ) | ||
181 | s3_bucket = AWS_STORAGE_BUCKET_NAME | 195 | s3_bucket = AWS_STORAGE_BUCKET_NAME |
182 | 196 | ||
183 | item = Item.objects.filter(item_id=pk) | 197 | item = Item.objects.filter(item_id=pk) |
... | @@ -239,29 +253,40 @@ class ItemViewSet(viewsets.ViewSet): | ... | @@ -239,29 +253,40 @@ class ItemViewSet(viewsets.ViewSet): |
239 | def move(self, request, pk): | 253 | def move(self, request, pk): |
240 | if request.method == 'POST': | 254 | if request.method == 'POST': |
241 | parent_id = request.POST.get('parent', '') | 255 | parent_id = request.POST.get('parent', '') |
242 | - name = request.POST.get('name', '') | 256 | + name = request.POST.get('name','') |
243 | - parent = get_object_or_None(Item, item_id=parent_id) | 257 | + child = get_object_or_None(Item, item_id=pk) |
244 | - if parent != None and parent.is_folder == True: | 258 | + |
245 | - child = get_object_or_None(Item, item_id=pk) | 259 | + if child == None: |
246 | - if child == None: | 260 | + return Response({'message': 'item is not existed.'}, status=status.HTTP_204_NO_CONTENT) |
247 | - return Response({'message': 'item is not existed.'}, status=status.HTTP_204_NO_CONTENT) | 261 | + |
248 | - child.parent = parent_id | 262 | + if parent_id != '': |
249 | - child.save() | 263 | + parent = get_object_or_None(Item, item_id=parent_id) |
250 | - child = Item.objects.filter(item_id=pk) | 264 | + |
251 | - child_data = serializers.serialize("json", child) | 265 | + if parent == None: |
252 | - json_child = json.loads(child_data) | 266 | + return Response({'message': 'parent is not existed.'}, status=status.HTTP_200_OK) |
253 | - res = json_child[0]['fields'] | 267 | + if parent.is_folder == False: |
254 | - res['id'] = pk | 268 | + return Response({'message': 'parent is not folder.'}, status=status.HTTP_200_OK) |
255 | - parent = Item.objects.filter(item_id=parent_id) | 269 | + |
256 | - parent_data = serializers.serialize("json", parent) | 270 | + if parent != None and parent.is_folder == True: |
257 | - json_parent = json.loads(parent_data)[0]['fields'] | 271 | + child.parent = parent_id |
258 | - res['parentInfo'] = json_parent | 272 | + else: |
259 | - return Response({'data': res}, status=status.HTTP_200_OK) | 273 | + parent_id = child.parent |
260 | - if parent == None: | 274 | + |
261 | - return Response({'message': 'parent is not existed.'}, status=status.HTTP_200_OK) | 275 | + if name != '': |
262 | - if parent.is_folder == False: | 276 | + child.name = name; |
263 | - return Response({'message': 'parent is not folder.'}, status=status.HTTP_200_OK) | 277 | + |
264 | - return Response({'message': 'item is not existed.'}, status=status.HTTP_204_NO_CONTENT) | 278 | + child.save() |
279 | + child = Item.objects.filter(item_id = pk) | ||
280 | + child_data = serializers.serialize("json", child) | ||
281 | + json_child = json.loads(child_data) | ||
282 | + res = json_child[0]['fields'] | ||
283 | + res['id'] = pk | ||
284 | + parent = Item.objects.filter(item_id = parent_id) | ||
285 | + parent_data = serializers.serialize("json", parent) | ||
286 | + json_parent = json.loads(parent_data)[0]['fields'] | ||
287 | + res['parentInfo'] = json_parent | ||
288 | + | ||
289 | + return Response({'data': res}, status=status.HTTP_200_OK) | ||
265 | 290 | ||
266 | @action(methods=['POST'], detail=True, permission_classes=[AllowAny], url_path='copy', url_name='copy') | 291 | @action(methods=['POST'], detail=True, permission_classes=[AllowAny], url_path='copy', url_name='copy') |
267 | def copy(self, request, pk): | 292 | def copy(self, request, pk): |
... | @@ -308,7 +333,7 @@ class ItemViewSet(viewsets.ViewSet): | ... | @@ -308,7 +333,7 @@ class ItemViewSet(viewsets.ViewSet): |
308 | url_path='children', url_name='children') | 333 | url_path='children', url_name='children') |
309 | def children(self, request, pk): | 334 | def children(self, request, pk): |
310 | if request.method == 'GET': | 335 | if request.method == 'GET': |
311 | - children = Item.objects.filter(parent=pk, is_deleted=False) | 336 | + children = Item.objects.filter(parent=pk, is_deleted=False, status=True) |
312 | children_data = serializers.serialize("json", children) | 337 | children_data = serializers.serialize("json", children) |
313 | json_children = json.loads(children_data) | 338 | json_children = json.loads(children_data) |
314 | parent = Item.objects.filter(item_id=pk) # item | 339 | parent = Item.objects.filter(item_id=pk) # item |
... | @@ -359,7 +384,15 @@ class ItemViewSet(viewsets.ViewSet): | ... | @@ -359,7 +384,15 @@ class ItemViewSet(viewsets.ViewSet): |
359 | url_path='upload', url_name='upload') | 384 | url_path='upload', url_name='upload') |
360 | def upload(self, request, pk): | 385 | def upload(self, request, pk): |
361 | if request.method == 'POST': | 386 | if request.method == 'POST': |
362 | - s3 = boto3.client('s3') | 387 | + s3 = boto3.client( |
388 | + 's3', | ||
389 | + region_name=AWS_REGION, | ||
390 | + aws_access_key_id=AWS_ACCESS_KEY_ID, | ||
391 | + aws_secret_access_key=AWS_SECRET_ACCESS_KEY, | ||
392 | + aws_session_token=AWS_SESSION_TOKEN, | ||
393 | + endpoint_url=AWS_ENDPOINT_URL or None, | ||
394 | + config=Config(s3={'addressing_style': 'path'}) | ||
395 | + ) | ||
363 | s3_bucket = AWS_STORAGE_BUCKET_NAME | 396 | s3_bucket = AWS_STORAGE_BUCKET_NAME |
364 | 397 | ||
365 | # 파일 객체 생성 | 398 | # 파일 객체 생성 |
... | @@ -378,6 +411,7 @@ class ItemViewSet(viewsets.ViewSet): | ... | @@ -378,6 +411,7 @@ class ItemViewSet(viewsets.ViewSet): |
378 | { | 411 | { |
379 | "acl": "private", | 412 | "acl": "private", |
380 | "Content-Type": file_type, | 413 | "Content-Type": file_type, |
414 | + "Content-Disposition": "attachment", | ||
381 | 'region': AWS_REGION, | 415 | 'region': AWS_REGION, |
382 | 'x-amz-algorithm': 'AWS4-HMAC-SHA256', | 416 | 'x-amz-algorithm': 'AWS4-HMAC-SHA256', |
383 | 'x-amz-date': date_long | 417 | 'x-amz-date': date_long |
... | @@ -385,18 +419,26 @@ class ItemViewSet(viewsets.ViewSet): | ... | @@ -385,18 +419,26 @@ class ItemViewSet(viewsets.ViewSet): |
385 | [ | 419 | [ |
386 | {"acl": "private"}, | 420 | {"acl": "private"}, |
387 | {"Content-Type": file_type}, | 421 | {"Content-Type": file_type}, |
422 | + {"Content-Disposition": "attachment"}, | ||
388 | {'x-amz-algorithm': 'AWS4-HMAC-SHA256'}, | 423 | {'x-amz-algorithm': 'AWS4-HMAC-SHA256'}, |
389 | {'x-amz-date': date_long} | 424 | {'x-amz-date': date_long} |
390 | ], | 425 | ], |
391 | 3600 | 426 | 3600 |
392 | ) | 427 | ) |
393 | 428 | ||
429 | + item = Item.objects.filter(item_id=upload_item.item_id) | ||
430 | + item_data = serializers.serialize("json", item) | ||
431 | + json_item = json.loads(item_data) | ||
432 | + res = json_item[0]['fields'] | ||
433 | + res['id'] = json_item[0]['pk'] | ||
434 | + | ||
394 | data = { | 435 | data = { |
395 | "signed_url": presigned_post, | 436 | "signed_url": presigned_post, |
396 | - 'url': 'https://%s.s3.amazonaws.com/%s' % (s3_bucket, file_name) | 437 | + 'url': '%s/%s' % (presigned_post["url"], file_name), |
438 | + 'item': res | ||
397 | } | 439 | } |
398 | 440 | ||
399 | - return Response({'presigned_post': presigned_post, 'proc_data': data}, status=status.HTTP_200_OK) | 441 | + return Response(data, status=status.HTTP_200_OK) |
400 | 442 | ||
401 | # url: /status/ | 443 | # url: /status/ |
402 | @action(methods=['POST'], detail=True, permission_classes=[AllowAny], | 444 | @action(methods=['POST'], detail=True, permission_classes=[AllowAny], | ... | ... |
backend/docker-compose.yml
0 → 100644
1 | +version: "3" | ||
2 | +services: | ||
3 | + postgres: | ||
4 | + image: "postgres:alpine" | ||
5 | + environment: | ||
6 | + - POSTGRES_USER=khudrive | ||
7 | + - POSTGRES_PASSWORD=4REPwb7y4CLtQaTv4PNeWRJeGLbHXn | ||
8 | + - POSTGRES_DB=khudrive | ||
9 | + ports: | ||
10 | + - "35432:5432" | ||
11 | + volumes: | ||
12 | + - ./docker/postgres:/var/lib/postgresql/data/ | ||
13 | + minio: | ||
14 | + image: "minio/minio" | ||
15 | + entrypoint: sh | ||
16 | + command: -c "mkdir -p /data/bucket && /usr/bin/minio server /data" | ||
17 | + environment: | ||
18 | + - MINIO_ACCESS_KEY=access_key | ||
19 | + - MINIO_SECRET_KEY=secret_key | ||
20 | + ports: | ||
21 | + - "39000:9000" | ||
22 | + volumes: | ||
23 | + - ./docker/minio:/data | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
... | @@ -88,11 +88,11 @@ DATABASES = { | ... | @@ -88,11 +88,11 @@ DATABASES = { |
88 | # } | 88 | # } |
89 | 'default': { | 89 | 'default': { |
90 | 'ENGINE': 'django.db.backends.postgresql', | 90 | 'ENGINE': 'django.db.backends.postgresql', |
91 | - 'NAME': 'drive', | 91 | + 'NAME': 'khudrive', |
92 | - 'USER': 'jooheekwon', | 92 | + 'USER': 'khudrive', |
93 | - 'PASSWORD': 'victoriawngml77', | 93 | + 'PASSWORD': '4REPwb7y4CLtQaTv4PNeWRJeGLbHXn', |
94 | 'HOST': 'localhost', | 94 | 'HOST': 'localhost', |
95 | - 'PORT': '', | 95 | + 'PORT': '35432', |
96 | } | 96 | } |
97 | } | 97 | } |
98 | 98 | ... | ... |
1 | asgiref==3.2.7 | 1 | asgiref==3.2.7 |
2 | +boto3==1.14.2 | ||
3 | +botocore==1.17.2 | ||
4 | +cffi==1.14.0 | ||
5 | +cryptography==2.9.2 | ||
2 | Django==3.0.6 | 6 | Django==3.0.6 |
7 | +django-annoying==0.10.6 | ||
3 | djangorestframework==3.11.0 | 8 | djangorestframework==3.11.0 |
9 | +docutils==0.15.2 | ||
10 | +jmespath==0.10.0 | ||
11 | +psycopg2==2.8.5 | ||
12 | +pycparser==2.20 | ||
13 | +PyJWT==1.7.1 | ||
14 | +python-dateutil==2.8.1 | ||
4 | pytz==2020.1 | 15 | pytz==2020.1 |
16 | +s3transfer==0.3.3 | ||
17 | +six==1.15.0 | ||
5 | sqlparse==0.3.1 | 18 | sqlparse==0.3.1 |
19 | +urllib3==1.25.9 | ... | ... |
... | @@ -21,6 +21,7 @@ | ... | @@ -21,6 +21,7 @@ |
21 | "react/display-name": "off", | 21 | "react/display-name": "off", |
22 | "react/prop-types": "off", | 22 | "react/prop-types": "off", |
23 | "no-empty": ["warn", { "allowEmptyCatch": true }], | 23 | "no-empty": ["warn", { "allowEmptyCatch": true }], |
24 | + "@typescript-eslint/camelcase": "off", | ||
24 | "@typescript-eslint/explicit-function-return-type": "off", | 25 | "@typescript-eslint/explicit-function-return-type": "off", |
25 | "@typescript-eslint/explicit-member-accessibility": "off", | 26 | "@typescript-eslint/explicit-member-accessibility": "off", |
26 | "@typescript-eslint/interface-name-prefix": "off", | 27 | "@typescript-eslint/interface-name-prefix": "off", | ... | ... |
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. |
2 | 2 | ||
3 | # dependencies | 3 | # dependencies |
4 | -/backend/env | 4 | +/node_modules |
5 | -/frontend/node_modules | 5 | +/.pnp |
6 | -/frontend/.pnp | ||
7 | .pnp.js | 6 | .pnp.js |
8 | 7 | ||
9 | # testing | 8 | # testing |
10 | -/frontend/coverage | 9 | +/coverage |
11 | 10 | ||
12 | # production | 11 | # production |
13 | -/frontend/build | 12 | +/build |
14 | - | ||
15 | -# database | ||
16 | -/backend/db.sqlite3 | ||
17 | 13 | ||
18 | # misc | 14 | # misc |
19 | .DS_Store | 15 | .DS_Store |
... | @@ -21,7 +17,6 @@ | ... | @@ -21,7 +17,6 @@ |
21 | .env.development.local | 17 | .env.development.local |
22 | .env.test.local | 18 | .env.test.local |
23 | .env.production.local | 19 | .env.production.local |
24 | -__pycache__ | ||
25 | 20 | ||
26 | npm-debug.log* | 21 | npm-debug.log* |
27 | yarn-debug.log* | 22 | yarn-debug.log* | ... | ... |
This diff is collapsed. Click to expand it.
... | @@ -4,37 +4,41 @@ | ... | @@ -4,37 +4,41 @@ |
4 | "description": "Dropbox alternative cloud file service", | 4 | "description": "Dropbox alternative cloud file service", |
5 | "private": true, | 5 | "private": true, |
6 | "dependencies": { | 6 | "dependencies": { |
7 | + "@ant-design/icons": "^4.2.1", | ||
8 | + "antd": "^4.3.3", | ||
7 | "classnames": "^2.2.6", | 9 | "classnames": "^2.2.6", |
8 | - "ky": "^0.19.1", | 10 | + "filesize": "^6.1.0", |
11 | + "ky": "^0.20.0", | ||
12 | + "miragejs": "^0.1.40", | ||
9 | "react": "^16.13.1", | 13 | "react": "^16.13.1", |
10 | "react-dom": "^16.13.1", | 14 | "react-dom": "^16.13.1", |
11 | - "react-router-dom": "^5.1.2" | 15 | + "react-router-dom": "^5.2.0" |
12 | }, | 16 | }, |
13 | "devDependencies": { | 17 | "devDependencies": { |
14 | "@hot-loader/react-dom": "^16.13.0", | 18 | "@hot-loader/react-dom": "^16.13.0", |
15 | - "@testing-library/jest-dom": "^5.7.0", | 19 | + "@testing-library/jest-dom": "^5.9.0", |
16 | - "@testing-library/react": "^10.0.4", | 20 | + "@testing-library/react": "^10.2.0", |
17 | - "@testing-library/user-event": "^10.1.2", | 21 | + "@testing-library/user-event": "^11.2.0", |
18 | "@types/classnames": "^2.2.10", | 22 | "@types/classnames": "^2.2.10", |
19 | - "@types/jest": "^25.2.1", | 23 | + "@types/jest": "^25.2.3", |
20 | "@types/node": "12", | 24 | "@types/node": "12", |
21 | "@types/react": "^16.9.35", | 25 | "@types/react": "^16.9.35", |
22 | "@types/react-dom": "^16.9.8", | 26 | "@types/react-dom": "^16.9.8", |
23 | "@types/react-router-dom": "^5.1.5", | 27 | "@types/react-router-dom": "^5.1.5", |
24 | - "@typescript-eslint/eslint-plugin": "^2.31.0", | 28 | + "@typescript-eslint/eslint-plugin": "^2.34.0", |
25 | - "@typescript-eslint/parser": "^2.31.0", | 29 | + "@typescript-eslint/parser": "^2.34.0", |
26 | - "customize-cra": "0.9.1", | 30 | + "customize-cra": "1.0.0", |
27 | "eslint-config-prettier": "^6.11.0", | 31 | "eslint-config-prettier": "^6.11.0", |
28 | - "eslint-plugin-jest": "^23.10.0", | 32 | + "eslint-plugin-jest": "^23.13.2", |
29 | "husky": "^4.2.5", | 33 | "husky": "^4.2.5", |
30 | - "lint-staged": "^10.2.2", | 34 | + "lint-staged": "^10.2.9", |
31 | "node-sass": "^4.14.1", | 35 | "node-sass": "^4.14.1", |
32 | "prettier": "^2.0.5", | 36 | "prettier": "^2.0.5", |
33 | "react-app-rewired": "^2.1.6", | 37 | "react-app-rewired": "^2.1.6", |
34 | "react-hot-loader": "^4.12.21", | 38 | "react-hot-loader": "^4.12.21", |
35 | "react-scripts": "3.4.1", | 39 | "react-scripts": "3.4.1", |
36 | - "typescript": "^3.8.3", | 40 | + "typescript": "^3.9.5", |
37 | - "webpack-bundle-analyzer": "^3.7.0" | 41 | + "webpack-bundle-analyzer": "^3.8.0" |
38 | }, | 42 | }, |
39 | "scripts": { | 43 | "scripts": { |
40 | "start": "react-app-rewired start", | 44 | "start": "react-app-rewired start", |
... | @@ -63,5 +67,6 @@ | ... | @@ -63,5 +67,6 @@ |
63 | }, | 67 | }, |
64 | "lint-staged": { | 68 | "lint-staged": { |
65 | "*.{js,ts,tsx,json,css,scss}": "prettier --write" | 69 | "*.{js,ts,tsx,json,css,scss}": "prettier --write" |
66 | - } | 70 | + }, |
71 | + "proxy": "http://localhost:8000" | ||
67 | } | 72 | } | ... | ... |
frontend/public/android-chrome-192x192.png
0 → 100644
1.67 KB
frontend/public/android-chrome-512x512.png
0 → 100644
5.26 KB
frontend/public/apple-touch-icon.png
0 → 100644
1.53 KB
frontend/public/browserconfig.xml
0 → 100644
frontend/public/favicon-16x16.png
0 → 100644
605 Bytes
frontend/public/favicon-32x32.png
0 → 100644
699 Bytes
No preview for this file type
... | @@ -2,42 +2,21 @@ | ... | @@ -2,42 +2,21 @@ |
2 | <html lang="en"> | 2 | <html lang="en"> |
3 | <head> | 3 | <head> |
4 | <meta charset="utf-8" /> | 4 | <meta charset="utf-8" /> |
5 | - <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> | ||
6 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | 5 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
7 | <meta name="theme-color" content="#000000" /> | 6 | <meta name="theme-color" content="#000000" /> |
8 | - <meta | 7 | + <meta name="description" content="KHUDrive" /> |
9 | - name="description" | 8 | + <link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png"> |
10 | - content="Web site created using create-react-app" | 9 | + <link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png"> |
11 | - /> | 10 | + <link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png"> |
12 | - <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> | 11 | + <link rel="manifest" href="%PUBLIC_URL%/site.webmanifest"> |
13 | - <!-- | 12 | + <link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#5bbad5"> |
14 | - manifest.json provides metadata used when your web app is installed on a | 13 | + <meta name="msapplication-TileColor" content="#da532c"> |
15 | - user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ | 14 | + <meta name="theme-color" content="#ffffff"> |
16 | - --> | ||
17 | <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> | 15 | <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> |
18 | - <!-- | 16 | + <title>KHUDrive</title> |
19 | - Notice the use of %PUBLIC_URL% in the tags above. | ||
20 | - It will be replaced with the URL of the `public` folder during the build. | ||
21 | - Only files inside the `public` folder can be referenced from the HTML. | ||
22 | - | ||
23 | - Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will | ||
24 | - work correctly both with client-side routing and a non-root public URL. | ||
25 | - Learn how to configure a non-root public URL by running `npm run build`. | ||
26 | - --> | ||
27 | - <title>React App</title> | ||
28 | </head> | 17 | </head> |
29 | <body> | 18 | <body> |
30 | <noscript>You need to enable JavaScript to run this app.</noscript> | 19 | <noscript>You need to enable JavaScript to run this app.</noscript> |
31 | <div id="root"></div> | 20 | <div id="root"></div> |
32 | - <!-- | ||
33 | - This HTML file is a template. | ||
34 | - If you open it directly in the browser, you will see an empty page. | ||
35 | - | ||
36 | - You can add webfonts, meta tags, or analytics to this file. | ||
37 | - The build step will place the bundled scripts into the <body> tag. | ||
38 | - | ||
39 | - To begin the development, run `npm start` or `yarn start`. | ||
40 | - To create a production bundle, use `npm run build` or `yarn build`. | ||
41 | - --> | ||
42 | </body> | 21 | </body> |
43 | </html> | 22 | </html> | ... | ... |
frontend/public/mstile-144x144.png
0 → 100644
1.3 KB
frontend/public/mstile-150x150.png
0 → 100644
1.33 KB
frontend/public/mstile-310x150.png
0 → 100644
1.49 KB
frontend/public/mstile-310x310.png
0 → 100644
2.74 KB
frontend/public/mstile-70x70.png
0 → 100644
984 Bytes
frontend/public/safari-pinned-tab.svg
0 → 100644
1 | +<?xml version="1.0" standalone="no"?> | ||
2 | +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" | ||
3 | + "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> | ||
4 | +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" | ||
5 | + width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000" | ||
6 | + preserveAspectRatio="xMidYMid meet"> | ||
7 | +<metadata> | ||
8 | +Created by potrace 1.11, written by Peter Selinger 2001-2013 | ||
9 | +</metadata> | ||
10 | +<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)" | ||
11 | +fill="#000000" stroke="none"> | ||
12 | +<path d="M300 6180 c-105 -20 -218 -112 -268 -218 l-27 -57 0 -2410 0 -2410 | ||
13 | +29 -58 c50 -98 123 -163 223 -196 l63 -21 3197 2 3198 3 65 31 c80 38 149 105 | ||
14 | +187 182 l28 57 0 2040 0 2040 -33 67 c-48 99 -134 169 -242 198 -34 9 -494 12 | ||
15 | +-1930 14 l-1885 1 -65 31 c-96 46 -150 109 -240 279 -70 132 -165 287 -194 | ||
16 | +317 -46 47 -112 86 -175 103 -38 10 -247 13 -972 12 -508 0 -940 -3 -959 -7z"/> | ||
17 | +</g> | ||
18 | +</svg> |
frontend/public/site.webmanifest
0 → 100644
1 | +{ | ||
2 | + "name": "", | ||
3 | + "short_name": "", | ||
4 | + "icons": [ | ||
5 | + { | ||
6 | + "src": "/android-chrome-192x192.png", | ||
7 | + "sizes": "192x192", | ||
8 | + "type": "image/png" | ||
9 | + }, | ||
10 | + { | ||
11 | + "src": "/android-chrome-512x512.png", | ||
12 | + "sizes": "512x512", | ||
13 | + "type": "image/png" | ||
14 | + } | ||
15 | + ], | ||
16 | + "theme_color": "#ffffff", | ||
17 | + "background_color": "#ffffff", | ||
18 | + "display": "standalone" | ||
19 | +} |
1 | import React from "react"; | 1 | import React from "react"; |
2 | +import { Switch, Route, Redirect } from "react-router-dom"; | ||
3 | + | ||
4 | +import { Login } from "auth/Login"; | ||
5 | +import { Signup } from "auth/Signup"; | ||
6 | +import { useAuth, TokenContext } from "auth/useAuth"; | ||
7 | + | ||
8 | +import { Page } from "layout/Page"; | ||
9 | +import { FileList } from "file/FileList"; | ||
2 | 10 | ||
3 | export function App() { | 11 | export function App() { |
4 | - return <div>Hello World!</div>; | 12 | + const token = useAuth(); |
13 | + const root = token?.token?.user.rootFolder; | ||
14 | + | ||
15 | + return ( | ||
16 | + <Switch> | ||
17 | + <Route path="/login"> | ||
18 | + <Login login={token.login} /> | ||
19 | + </Route> | ||
20 | + <Route path="/signup"> | ||
21 | + <Signup /> | ||
22 | + </Route> | ||
23 | + <Route> | ||
24 | + {token.token !== null ? ( | ||
25 | + <TokenContext.Provider value={token}> | ||
26 | + <Page> | ||
27 | + <Switch> | ||
28 | + <Route path="/folder/:id"> | ||
29 | + <FileList /> | ||
30 | + </Route> | ||
31 | + <Route> | ||
32 | + <Redirect to={`/folder/${root}`} /> | ||
33 | + </Route> | ||
34 | + </Switch> | ||
35 | + </Page> | ||
36 | + </TokenContext.Provider> | ||
37 | + ) : ( | ||
38 | + <Redirect to="/login" /> | ||
39 | + )} | ||
40 | + </Route> | ||
41 | + </Switch> | ||
42 | + ); | ||
5 | } | 43 | } | ... | ... |
frontend/src/auth/Login.module.scss
0 → 100644
1 | +.layout { | ||
2 | + height: 100%; | ||
3 | + align-items: center; | ||
4 | + justify-content: center; | ||
5 | +} | ||
6 | + | ||
7 | +.content { | ||
8 | + width: 640px; | ||
9 | + flex-grow: 0; | ||
10 | + background: #fff; | ||
11 | + padding: 80px 50px 50px; | ||
12 | +} | ||
13 | + | ||
14 | +#components-form-demo-normal-login .login-form-forgot { | ||
15 | + float: right; | ||
16 | +} | ||
17 | + | ||
18 | +#components-form-demo-normal-login .ant-col-rtl .login-form-forgot { | ||
19 | + float: left; | ||
20 | +} | ||
21 | + | ||
22 | +#components-form-demo-normal-login .login-form-button { | ||
23 | + width: 100%; | ||
24 | +} |
frontend/src/auth/Login.tsx
0 → 100644
1 | +import React, { useCallback, useState } from "react"; | ||
2 | +import { Form, Input, Button, Checkbox, Layout } from "antd"; | ||
3 | +import { UserOutlined, LockOutlined } from "@ant-design/icons"; | ||
4 | +import { useHistory, Link } from "react-router-dom"; | ||
5 | + | ||
6 | +import styles from "./Login.module.scss"; | ||
7 | + | ||
8 | +export type LoginProps = { | ||
9 | + login: ( | ||
10 | + username: string, | ||
11 | + password: string, | ||
12 | + remember: boolean | ||
13 | + ) => Promise<void>; | ||
14 | +}; | ||
15 | + | ||
16 | +export function Login({ login }: LoginProps) { | ||
17 | + const [error, setError] = useState<boolean>(false); | ||
18 | + const history = useHistory(); | ||
19 | + | ||
20 | + const handleLogin = useCallback( | ||
21 | + async ({ username, password, remember }) => { | ||
22 | + setError(false); | ||
23 | + try { | ||
24 | + await login(username, password, remember); | ||
25 | + history.push("/"); | ||
26 | + } catch { | ||
27 | + setError(true); | ||
28 | + } | ||
29 | + }, | ||
30 | + [login, history] | ||
31 | + ); | ||
32 | + | ||
33 | + return ( | ||
34 | + <Layout className={styles.layout}> | ||
35 | + <Layout.Content className={styles.content}> | ||
36 | + <Form | ||
37 | + name="login" | ||
38 | + initialValues={{ remember: true }} | ||
39 | + onFinish={handleLogin} | ||
40 | + > | ||
41 | + <Form.Item | ||
42 | + name="username" | ||
43 | + rules={[{ required: true, message: "아이디를 입력하세요" }]} | ||
44 | + {...(error && { | ||
45 | + validateStatus: "error", | ||
46 | + })} | ||
47 | + > | ||
48 | + <Input prefix={<UserOutlined />} placeholder="아이디" /> | ||
49 | + </Form.Item> | ||
50 | + <Form.Item | ||
51 | + name="password" | ||
52 | + rules={[{ required: true, message: "비밀번호를 입력하세요" }]} | ||
53 | + {...(error && { | ||
54 | + validateStatus: "error", | ||
55 | + help: "로그인에 실패했습니다", | ||
56 | + })} | ||
57 | + > | ||
58 | + <Input | ||
59 | + prefix={<LockOutlined />} | ||
60 | + type="password" | ||
61 | + placeholder="비밀번호" | ||
62 | + /> | ||
63 | + </Form.Item> | ||
64 | + <Form.Item> | ||
65 | + <Form.Item name="remember" valuePropName="checked" noStyle> | ||
66 | + <Checkbox>자동 로그인</Checkbox> | ||
67 | + </Form.Item> | ||
68 | + </Form.Item> | ||
69 | + | ||
70 | + <Form.Item> | ||
71 | + <Button type="primary" htmlType="submit"> | ||
72 | + 로그인 | ||
73 | + </Button> | ||
74 | + <Link to="/signup" style={{ marginLeft: 24 }}> | ||
75 | + 회원가입 | ||
76 | + </Link> | ||
77 | + </Form.Item> | ||
78 | + </Form> | ||
79 | + </Layout.Content> | ||
80 | + </Layout> | ||
81 | + ); | ||
82 | +} |
frontend/src/auth/Signup.tsx
0 → 100644
1 | +import React, { useCallback, useState } from "react"; | ||
2 | +import { Form, Input, Button, Layout, message } from "antd"; | ||
3 | +import { UserOutlined, LockOutlined, TagOutlined } from "@ant-design/icons"; | ||
4 | +import { useHistory } from "react-router-dom"; | ||
5 | + | ||
6 | +import styles from "./Login.module.scss"; | ||
7 | +import ky from "ky"; | ||
8 | + | ||
9 | +export function Signup() { | ||
10 | + const [error, setError] = useState<boolean>(false); | ||
11 | + const [check, setCheck] = useState<boolean>(false); | ||
12 | + const history = useHistory(); | ||
13 | + | ||
14 | + const handleSignup = useCallback( | ||
15 | + async ({ user_id, password, password_check, name }) => { | ||
16 | + if (password !== password_check) { | ||
17 | + return setCheck(true); | ||
18 | + } else { | ||
19 | + setCheck(false); | ||
20 | + } | ||
21 | + | ||
22 | + setError(false); | ||
23 | + try { | ||
24 | + const body = new URLSearchParams(); | ||
25 | + body.set("user_id", user_id); | ||
26 | + body.set("password", password); | ||
27 | + body.set("name", name); | ||
28 | + | ||
29 | + await ky.post("/users/signup/", { body }); | ||
30 | + | ||
31 | + message.success("회원가입이 완료되었습니다"); | ||
32 | + history.push("/login"); | ||
33 | + } catch { | ||
34 | + setError(true); | ||
35 | + } | ||
36 | + }, | ||
37 | + [history] | ||
38 | + ); | ||
39 | + | ||
40 | + return ( | ||
41 | + <Layout className={styles.layout}> | ||
42 | + <Layout.Content className={styles.content}> | ||
43 | + <Form name="signup" onFinish={handleSignup}> | ||
44 | + <Form.Item | ||
45 | + name="user_id" | ||
46 | + rules={[{ required: true, message: "아이디를 입력하세요" }]} | ||
47 | + {...(error && { | ||
48 | + validateStatus: "error", | ||
49 | + })} | ||
50 | + > | ||
51 | + <Input prefix={<UserOutlined />} placeholder="아이디" /> | ||
52 | + </Form.Item> | ||
53 | + <Form.Item | ||
54 | + name="password" | ||
55 | + rules={[{ required: true, message: "비밀번호를 입력하세요" }]} | ||
56 | + {...(error && { | ||
57 | + validateStatus: "error", | ||
58 | + help: "로그인에 실패했습니다", | ||
59 | + })} | ||
60 | + > | ||
61 | + <Input | ||
62 | + prefix={<LockOutlined />} | ||
63 | + type="password" | ||
64 | + placeholder="비밀번호" | ||
65 | + /> | ||
66 | + </Form.Item> | ||
67 | + <Form.Item | ||
68 | + name="password_check" | ||
69 | + rules={[ | ||
70 | + { required: true, message: "비밀번호를 한번 더 입력하세요" }, | ||
71 | + ]} | ||
72 | + {...(error && { | ||
73 | + validateStatus: "error", | ||
74 | + help: "로그인에 실패했습니다", | ||
75 | + })} | ||
76 | + {...(check && { | ||
77 | + validateStatus: "error", | ||
78 | + help: "비밀번호가 일치하지 않습니다", | ||
79 | + })} | ||
80 | + > | ||
81 | + <Input | ||
82 | + prefix={<LockOutlined />} | ||
83 | + type="password" | ||
84 | + placeholder="비밀번호 확인" | ||
85 | + /> | ||
86 | + </Form.Item> | ||
87 | + <Form.Item | ||
88 | + name="name" | ||
89 | + rules={[{ required: true, message: "이름을 입력하세요" }]} | ||
90 | + {...(error && { | ||
91 | + validateStatus: "error", | ||
92 | + })} | ||
93 | + > | ||
94 | + <Input prefix={<TagOutlined />} placeholder="이름" /> | ||
95 | + </Form.Item> | ||
96 | + | ||
97 | + <Form.Item> | ||
98 | + <Button type="primary" htmlType="submit"> | ||
99 | + 회원 가입 | ||
100 | + </Button> | ||
101 | + </Form.Item> | ||
102 | + </Form> | ||
103 | + </Layout.Content> | ||
104 | + </Layout> | ||
105 | + ); | ||
106 | +} |
frontend/src/auth/useAuth.ts
0 → 100644
1 | +import React, { useState, useCallback } from "react"; | ||
2 | +import ky from "ky"; | ||
3 | + | ||
4 | +interface LoginResponse { | ||
5 | + access: string; | ||
6 | + refresh: string; | ||
7 | + exp: number; | ||
8 | + user: { | ||
9 | + int_id: number; | ||
10 | + user_id: string; | ||
11 | + name: string; | ||
12 | + total_size: number; | ||
13 | + current_size: number; | ||
14 | + root_folder: number; | ||
15 | + }; | ||
16 | +} | ||
17 | + | ||
18 | +interface Token { | ||
19 | + accessToken: string; | ||
20 | + refreshToken: string; | ||
21 | + expiration: Date; | ||
22 | + user: { | ||
23 | + id: number; | ||
24 | + username: string; | ||
25 | + name: string; | ||
26 | + totalSize: number; | ||
27 | + currentSize: number; | ||
28 | + rootFolder: number; | ||
29 | + }; | ||
30 | +} | ||
31 | + | ||
32 | +export const TokenContext = React.createContext<ReturnType<typeof useAuth>>( | ||
33 | + {} as any | ||
34 | +); | ||
35 | + | ||
36 | +export function useAuth() { | ||
37 | + const [token, setToken] = useState<Token | null>(() => { | ||
38 | + const item = localStorage.getItem("token"); | ||
39 | + if (item) { | ||
40 | + const token = JSON.parse(item); | ||
41 | + token.expiration = new Date(token.expiration); | ||
42 | + return token; | ||
43 | + } | ||
44 | + return null; | ||
45 | + }); | ||
46 | + | ||
47 | + const login = useCallback( | ||
48 | + async (username: string, password: string, remember: boolean) => { | ||
49 | + const body = new URLSearchParams(); | ||
50 | + body.set("user_id", username); | ||
51 | + body.set("password", password); | ||
52 | + | ||
53 | + const response = await ky | ||
54 | + .post("/users/login/", { body }) | ||
55 | + .json<LoginResponse>(); | ||
56 | + | ||
57 | + const token = { | ||
58 | + accessToken: response.access, | ||
59 | + refreshToken: response.refresh, | ||
60 | + expiration: new Date(response.exp * 1000), | ||
61 | + user: { | ||
62 | + id: response.user.int_id, | ||
63 | + username: response.user.user_id, | ||
64 | + name: response.user.name, | ||
65 | + totalSize: response.user.total_size, | ||
66 | + currentSize: response.user.current_size, | ||
67 | + rootFolder: response.user.root_folder, | ||
68 | + }, | ||
69 | + }; | ||
70 | + | ||
71 | + setToken(token); | ||
72 | + | ||
73 | + if (remember) { | ||
74 | + localStorage.setItem("token", JSON.stringify(token)); | ||
75 | + } | ||
76 | + }, | ||
77 | + [] | ||
78 | + ); | ||
79 | + | ||
80 | + const logout = useCallback(() => setToken(null), []); | ||
81 | + | ||
82 | + return { token, login, logout }; | ||
83 | +} |
frontend/src/file/CreateFolderPopover.tsx
0 → 100644
1 | +import React, { useState } from "react"; | ||
2 | +import { Button, Input } from "antd"; | ||
3 | + | ||
4 | +export type CreateFolderPopoverProps = { | ||
5 | + onCreate: (name: string) => void; | ||
6 | + onCancel?: () => void; | ||
7 | +}; | ||
8 | + | ||
9 | +export function CreateFolderPopover({ | ||
10 | + onCreate, | ||
11 | + onCancel, | ||
12 | +}: CreateFolderPopoverProps) { | ||
13 | + const [name, setName] = useState<string>(""); | ||
14 | + return ( | ||
15 | + <div> | ||
16 | + <Input | ||
17 | + value={name} | ||
18 | + onChange={(event) => setName(event.target.value)} | ||
19 | + placeholder="이름" | ||
20 | + style={{ marginBottom: 10 }} | ||
21 | + /> | ||
22 | + <div className="ant-popover-buttons"> | ||
23 | + <Button size="small" onClick={onCancel}> | ||
24 | + 취소 | ||
25 | + </Button> | ||
26 | + <Button type="primary" size="small" onClick={() => onCreate(name)}> | ||
27 | + 생성 | ||
28 | + </Button> | ||
29 | + </div> | ||
30 | + </div> | ||
31 | + ); | ||
32 | +} |
File mode changed
frontend/src/file/FileItemActions.tsx
0 → 100644
1 | +import React, { useState, Fragment } from "react"; | ||
2 | +import { Popconfirm, Popover, Button, message } from "antd"; | ||
3 | +import { FileItem } from "./useFileList"; | ||
4 | +import styles from "./FileItemActions.module.scss"; | ||
5 | +import { FileListPopover } from "./FileListPopover"; | ||
6 | +import { FileRenamePopover } from "./FileRenamePopover"; | ||
7 | + | ||
8 | +export type FileItemActionsProps = { | ||
9 | + item: FileItem; | ||
10 | + onRename: (id: number, name: string) => void; | ||
11 | + onMove: (id: number, to: number) => void; | ||
12 | + onCopy: (id: number, to: number) => void; | ||
13 | + onDelete: (id: number) => void; | ||
14 | +}; | ||
15 | + | ||
16 | +export function FileItemActions({ | ||
17 | + item, | ||
18 | + onRename, | ||
19 | + onMove, | ||
20 | + onCopy, | ||
21 | + onDelete, | ||
22 | +}: FileItemActionsProps) { | ||
23 | + const [rename, setRename] = useState<boolean>(false); | ||
24 | + const [move, setMove] = useState<boolean>(false); | ||
25 | + const [copy, setCopy] = useState<boolean>(false); | ||
26 | + | ||
27 | + return ( | ||
28 | + <div className={styles.actions}> | ||
29 | + <Popover | ||
30 | + title="변경할 이름을 입력하세요" | ||
31 | + content={ | ||
32 | + <FileRenamePopover | ||
33 | + name={item.name} | ||
34 | + onRename={(name: string) => { | ||
35 | + if (name === item.name) { | ||
36 | + return message.error("동일한 이름으로는 변경할 수 없습니다"); | ||
37 | + } | ||
38 | + if (!name) { | ||
39 | + return message.error("변경할 이름을 입력하세요"); | ||
40 | + } | ||
41 | + onRename(item.id, name); | ||
42 | + setRename(false); | ||
43 | + }} | ||
44 | + onCancel={() => setRename(false)} | ||
45 | + /> | ||
46 | + } | ||
47 | + trigger="click" | ||
48 | + visible={rename} | ||
49 | + onVisibleChange={setRename} | ||
50 | + > | ||
51 | + <Button type="link" size="small"> | ||
52 | + 이름 변경 | ||
53 | + </Button> | ||
54 | + </Popover> | ||
55 | + {!item.is_folder && ( | ||
56 | + <Button type="link" size="small"> | ||
57 | + 공유 | ||
58 | + </Button> | ||
59 | + )} | ||
60 | + <Popover | ||
61 | + title="이동할 폴더를 선택하세요" | ||
62 | + content={ | ||
63 | + <FileListPopover | ||
64 | + root={item.parent} | ||
65 | + onSelect={(to: number) => { | ||
66 | + if (to === item.parent) { | ||
67 | + return message.error("같은 폴더로는 이동할 수 없습니다"); | ||
68 | + } | ||
69 | + onMove(item.id, to); | ||
70 | + setMove(false); | ||
71 | + }} | ||
72 | + onCancel={() => setMove(false)} | ||
73 | + /> | ||
74 | + } | ||
75 | + trigger="click" | ||
76 | + visible={move} | ||
77 | + onVisibleChange={setMove} | ||
78 | + > | ||
79 | + <Button type="link" size="small"> | ||
80 | + 이동 | ||
81 | + </Button> | ||
82 | + </Popover> | ||
83 | + {!item.is_folder && ( | ||
84 | + <Popover | ||
85 | + title="복사할 폴더를 선택하세요" | ||
86 | + content={ | ||
87 | + <FileListPopover | ||
88 | + root={item.parent} | ||
89 | + onSelect={(to: number) => { | ||
90 | + onCopy(item.id, to); | ||
91 | + setCopy(false); | ||
92 | + }} | ||
93 | + onCancel={() => setCopy(false)} | ||
94 | + /> | ||
95 | + } | ||
96 | + trigger="click" | ||
97 | + visible={copy} | ||
98 | + onVisibleChange={setCopy} | ||
99 | + > | ||
100 | + <Button type="link" size="small"> | ||
101 | + 복사 | ||
102 | + </Button> | ||
103 | + </Popover> | ||
104 | + )} | ||
105 | + {!item.is_folder && ( | ||
106 | + <Popconfirm | ||
107 | + title="정말로 삭제하시겠습니까?" | ||
108 | + onConfirm={() => onDelete(item.id)} | ||
109 | + okText="삭제" | ||
110 | + cancelText="취소" | ||
111 | + > | ||
112 | + <Button type="link" size="small"> | ||
113 | + 삭제 | ||
114 | + </Button> | ||
115 | + </Popconfirm> | ||
116 | + )} | ||
117 | + </div> | ||
118 | + ); | ||
119 | +} |
frontend/src/file/FileList.module.scss
0 → 100644
frontend/src/file/FileList.tsx
0 → 100644
1 | +import React, { useCallback, useState, useContext } from "react"; | ||
2 | +import { Table, message, Button, Popover } from "antd"; | ||
3 | +import { ColumnsType } from "antd/lib/table"; | ||
4 | +import filesize from "filesize"; | ||
5 | + | ||
6 | +import { useParams } from "react-router-dom"; | ||
7 | +import { useFileList, FileItem } from "./useFileList"; | ||
8 | +import { useApi } from "util/useApi"; | ||
9 | +import { FileListItem } from "./FileListItem"; | ||
10 | +import { FileItemActions } from "./FileItemActions"; | ||
11 | + | ||
12 | +import styles from "./FileList.module.scss"; | ||
13 | +import { FileUploadPopover } from "./FileUploadPopover"; | ||
14 | +import { CreateFolderPopover } from "./CreateFolderPopover"; | ||
15 | +import { TokenContext } from "auth/useAuth"; | ||
16 | + | ||
17 | +export function FileList() { | ||
18 | + const id = useParams<{ id: string }>().id; | ||
19 | + const { data, reload } = useFileList(id); | ||
20 | + | ||
21 | + const [upload, setUpload] = useState<boolean>(false); | ||
22 | + const [createFolder, setCreateFolder] = useState<boolean>(false); | ||
23 | + | ||
24 | + const { token } = useContext(TokenContext); | ||
25 | + const userId = token?.user.id || ""; | ||
26 | + | ||
27 | + const api = useApi(); | ||
28 | + | ||
29 | + const handleCreateFolder = useCallback( | ||
30 | + async (id: number, name: string) => { | ||
31 | + try { | ||
32 | + const body = new URLSearchParams(); | ||
33 | + body.set("name", name); | ||
34 | + | ||
35 | + await api.post(`/items/${id}/children/`, { | ||
36 | + searchParams: { | ||
37 | + user_id: userId, | ||
38 | + }, | ||
39 | + body, | ||
40 | + }); | ||
41 | + await reload(); | ||
42 | + | ||
43 | + message.info("폴더가 생성되었습니다"); | ||
44 | + } catch { | ||
45 | + message.error("폴더 생성에 실패했습니다"); | ||
46 | + } | ||
47 | + }, | ||
48 | + [api, reload, userId] | ||
49 | + ); | ||
50 | + | ||
51 | + const handleRename = useCallback( | ||
52 | + async (id: number, name: string) => { | ||
53 | + try { | ||
54 | + const body = new URLSearchParams(); | ||
55 | + body.set("name", name); | ||
56 | + | ||
57 | + await api.post(`/items/${id}/move/`, { body }); | ||
58 | + await reload(); | ||
59 | + | ||
60 | + message.info("이름이 변경되었습니다"); | ||
61 | + } catch { | ||
62 | + message.error("이름 변경에 실패했습니다"); | ||
63 | + } | ||
64 | + }, | ||
65 | + [api, reload] | ||
66 | + ); | ||
67 | + | ||
68 | + const handleMove = useCallback( | ||
69 | + async (id: number, to: number) => { | ||
70 | + try { | ||
71 | + const body = new URLSearchParams(); | ||
72 | + body.set("parent", to.toString(10)); | ||
73 | + | ||
74 | + await api.post(`/items/${id}/move/`, { body }); | ||
75 | + await reload(); | ||
76 | + | ||
77 | + message.info("이동되었습니다"); | ||
78 | + } catch { | ||
79 | + message.error("파일 이동에 실패했습니다"); | ||
80 | + } | ||
81 | + }, | ||
82 | + [api, reload] | ||
83 | + ); | ||
84 | + | ||
85 | + const handleCopy = useCallback( | ||
86 | + async (id: number, to: number) => { | ||
87 | + try { | ||
88 | + const body = new URLSearchParams(); | ||
89 | + body.set("parent", to.toString(10)); | ||
90 | + | ||
91 | + await api.post(`/items/${id}/copy/`, { body }); | ||
92 | + await reload(); | ||
93 | + | ||
94 | + message.info("복사되었습니다"); | ||
95 | + } catch { | ||
96 | + message.error("파일 복사에 실패했습니다"); | ||
97 | + } | ||
98 | + }, | ||
99 | + [api, reload] | ||
100 | + ); | ||
101 | + | ||
102 | + const handleDelete = useCallback( | ||
103 | + async (id: number) => { | ||
104 | + try { | ||
105 | + await api.delete(`/items/${id}/`); | ||
106 | + await reload(); | ||
107 | + message.info("삭제되었습니다"); | ||
108 | + } catch { | ||
109 | + message.error("파일 삭제에 실패했습니다"); | ||
110 | + } | ||
111 | + }, | ||
112 | + [api, reload] | ||
113 | + ); | ||
114 | + | ||
115 | + if (!data) { | ||
116 | + return null; | ||
117 | + } | ||
118 | + | ||
119 | + const list = [...data.list].sort((itemA, itemB) => | ||
120 | + itemA.is_folder === itemB.is_folder ? 0 : itemA.is_folder ? -1 : 1 | ||
121 | + ); | ||
122 | + | ||
123 | + if (data.parent !== null) { | ||
124 | + list.unshift(({ | ||
125 | + id: data.parent, | ||
126 | + is_folder: true, | ||
127 | + name: "..", | ||
128 | + file_type: "folder", | ||
129 | + } as unknown) as FileItem); | ||
130 | + } | ||
131 | + | ||
132 | + return ( | ||
133 | + <div> | ||
134 | + <div className={styles.header}> | ||
135 | + <div>{data.parent !== null && <h3>{data.name}</h3>}</div> | ||
136 | + <div> | ||
137 | + <Popover | ||
138 | + content={<FileUploadPopover root={data.id} reload={reload} />} | ||
139 | + trigger="click" | ||
140 | + visible={upload} | ||
141 | + onVisibleChange={setUpload} | ||
142 | + > | ||
143 | + <Button type="link" size="small"> | ||
144 | + 파일 업로드 | ||
145 | + </Button> | ||
146 | + </Popover> | ||
147 | + <Popover | ||
148 | + title="폴더 이름을 입력하세요" | ||
149 | + content={ | ||
150 | + <CreateFolderPopover | ||
151 | + onCreate={(name: string) => { | ||
152 | + if (!name) { | ||
153 | + return message.error("폴더 이름을 입력하세요"); | ||
154 | + } | ||
155 | + handleCreateFolder(data.id, name); | ||
156 | + setCreateFolder(false); | ||
157 | + }} | ||
158 | + onCancel={() => setCreateFolder(false)} | ||
159 | + /> | ||
160 | + } | ||
161 | + trigger="click" | ||
162 | + visible={createFolder} | ||
163 | + onVisibleChange={setCreateFolder} | ||
164 | + > | ||
165 | + <Button type="link" size="small"> | ||
166 | + 새 폴더 | ||
167 | + </Button> | ||
168 | + </Popover> | ||
169 | + </div> | ||
170 | + </div> | ||
171 | + <Table | ||
172 | + rowKey="id" | ||
173 | + columns={getColumns({ | ||
174 | + handleRename, | ||
175 | + handleMove, | ||
176 | + handleCopy, | ||
177 | + handleDelete, | ||
178 | + })} | ||
179 | + dataSource={list} | ||
180 | + pagination={false} | ||
181 | + locale={{ | ||
182 | + emptyText: "파일이 없습니다", | ||
183 | + }} | ||
184 | + /> | ||
185 | + </div> | ||
186 | + ); | ||
187 | +} | ||
188 | + | ||
189 | +type GetColumnsParams = { | ||
190 | + handleRename: (id: number, name: string) => void; | ||
191 | + handleMove: (id: number, to: number) => void; | ||
192 | + handleCopy: (id: number, to: number) => void; | ||
193 | + handleDelete: (id: number) => void; | ||
194 | +}; | ||
195 | + | ||
196 | +function getColumns({ | ||
197 | + handleRename, | ||
198 | + handleMove, | ||
199 | + handleCopy, | ||
200 | + handleDelete, | ||
201 | +}: GetColumnsParams): ColumnsType<FileItem> { | ||
202 | + return [ | ||
203 | + { | ||
204 | + title: "이름", | ||
205 | + key: "name", | ||
206 | + dataIndex: "name", | ||
207 | + render: (_name: string, item) => <FileListItem item={item} />, | ||
208 | + }, | ||
209 | + { | ||
210 | + title: "크기", | ||
211 | + key: "size", | ||
212 | + dataIndex: "size", | ||
213 | + width: 120, | ||
214 | + render: (bytes: number, item) => | ||
215 | + item.is_folder ? "-" : filesize(bytes, { round: 0 }), | ||
216 | + }, | ||
217 | + { | ||
218 | + title: "", | ||
219 | + key: "action", | ||
220 | + dataIndex: "", | ||
221 | + width: 300, | ||
222 | + render: (__: any, item) => ( | ||
223 | + <FileItemActions | ||
224 | + item={item} | ||
225 | + onRename={handleRename} | ||
226 | + onMove={handleMove} | ||
227 | + onCopy={handleCopy} | ||
228 | + onDelete={handleDelete} | ||
229 | + /> | ||
230 | + ), | ||
231 | + }, | ||
232 | + ]; | ||
233 | +} |
frontend/src/file/FileListItem.tsx
0 → 100644
1 | +import React from "react"; | ||
2 | +import { FileItem } from "./useFileList"; | ||
3 | +import { Link } from "react-router-dom"; | ||
4 | +import { Button } from "antd"; | ||
5 | + | ||
6 | +import { FolderFilled, FileFilled } from "@ant-design/icons"; | ||
7 | +import { useDownload } from "./useDownload"; | ||
8 | + | ||
9 | +export function FileListItem({ item }: { item: FileItem }) { | ||
10 | + const download = useDownload(); | ||
11 | + return item.is_folder ? ( | ||
12 | + <Link | ||
13 | + className="ant-btn ant-btn-link ant-btn-sm" | ||
14 | + to={`/folder/${item.id}`} | ||
15 | + style={{ padding: 0, color: "#001529" }} | ||
16 | + > | ||
17 | + <FolderFilled /> <span>{item.name}</span> | ||
18 | + </Link> | ||
19 | + ) : ( | ||
20 | + <Button | ||
21 | + type="link" | ||
22 | + size="small" | ||
23 | + onClick={() => download(item.id)} | ||
24 | + style={{ padding: 0, color: "#001529" }} | ||
25 | + > | ||
26 | + <FileFilled /> {item.name} | ||
27 | + </Button> | ||
28 | + ); | ||
29 | +} |
frontend/src/file/FileListPopover.tsx
0 → 100644
1 | +import React, { useState } from "react"; | ||
2 | +import { useFileList } from "./useFileList"; | ||
3 | +import { Button } from "antd"; | ||
4 | + | ||
5 | +import styles from "./FileListPopover.module.scss"; | ||
6 | + | ||
7 | +export type FileListPopoverProps = { | ||
8 | + root: number; | ||
9 | + onSelect: (id: number) => void; | ||
10 | + onCancel?: () => void; | ||
11 | +}; | ||
12 | + | ||
13 | +export function FileListPopover({ | ||
14 | + root, | ||
15 | + onSelect, | ||
16 | + onCancel, | ||
17 | +}: FileListPopoverProps) { | ||
18 | + const [id, setId] = useState<number>(root); | ||
19 | + const { data } = useFileList(id); | ||
20 | + | ||
21 | + if (!data) { | ||
22 | + return null; | ||
23 | + } | ||
24 | + | ||
25 | + const list = data.list | ||
26 | + .filter((item) => item.is_folder) | ||
27 | + .map((item) => ({ id: item.id, name: item.name })); | ||
28 | + | ||
29 | + if (data.parent !== null) { | ||
30 | + list.unshift({ id: data.parent, name: ".." }); | ||
31 | + } | ||
32 | + | ||
33 | + return ( | ||
34 | + <div> | ||
35 | + <div>{data.name}</div> | ||
36 | + <ul className={styles.list}> | ||
37 | + {list.map((item) => ( | ||
38 | + <li key={item.id}> | ||
39 | + <Button type="link" size="small" onClick={() => setId(item.id)}> | ||
40 | + {item.name} | ||
41 | + </Button> | ||
42 | + </li> | ||
43 | + ))} | ||
44 | + </ul> | ||
45 | + <div className="ant-popover-buttons"> | ||
46 | + <Button size="small" onClick={onCancel}> | ||
47 | + 취소 | ||
48 | + </Button> | ||
49 | + <Button type="primary" size="small" onClick={() => onSelect(id)}> | ||
50 | + 선택 | ||
51 | + </Button> | ||
52 | + </div> | ||
53 | + </div> | ||
54 | + ); | ||
55 | +} |
frontend/src/file/FileRenamePopover.tsx
0 → 100644
1 | +import React, { useState } from "react"; | ||
2 | +import { Button, Input } from "antd"; | ||
3 | + | ||
4 | +export type FileRenamePopoverProps = { | ||
5 | + name: string; | ||
6 | + onRename: (name: string) => void; | ||
7 | + onCancel?: () => void; | ||
8 | +}; | ||
9 | + | ||
10 | +export function FileRenamePopover({ | ||
11 | + name: oldName, | ||
12 | + onRename, | ||
13 | + onCancel, | ||
14 | +}: FileRenamePopoverProps) { | ||
15 | + const [name, setName] = useState<string>(oldName); | ||
16 | + return ( | ||
17 | + <div> | ||
18 | + <Input | ||
19 | + value={name} | ||
20 | + onChange={(event) => setName(event.target.value)} | ||
21 | + placeholder="이름" | ||
22 | + style={{ marginBottom: 10 }} | ||
23 | + /> | ||
24 | + <div className="ant-popover-buttons"> | ||
25 | + <Button size="small" onClick={onCancel}> | ||
26 | + 취소 | ||
27 | + </Button> | ||
28 | + <Button type="primary" size="small" onClick={() => onRename(name)}> | ||
29 | + 변경 | ||
30 | + </Button> | ||
31 | + </div> | ||
32 | + </div> | ||
33 | + ); | ||
34 | +} |
frontend/src/file/FileUploadPopover.tsx
0 → 100644
1 | +import React, { useCallback, useRef } from "react"; | ||
2 | +import Dragger from "antd/lib/upload/Dragger"; | ||
3 | +import { InboxOutlined } from "@ant-design/icons"; | ||
4 | +import { useApi } from "util/useApi"; | ||
5 | + | ||
6 | +export type FileUploadPopoverProps = { | ||
7 | + root: number; | ||
8 | + reload: () => void; | ||
9 | +}; | ||
10 | + | ||
11 | +export function FileUploadPopover({ root, reload }: FileUploadPopoverProps) { | ||
12 | + const api = useApi(); | ||
13 | + const fields = useRef<any>(); | ||
14 | + const stateMap = useRef<Record<string, number>>({}); | ||
15 | + | ||
16 | + const getS3Object = useCallback( | ||
17 | + async (file: File) => { | ||
18 | + const body = new URLSearchParams(); | ||
19 | + body.set("name", file.name); | ||
20 | + body.set("size", file.size.toString()); | ||
21 | + | ||
22 | + const response = await api | ||
23 | + .post(`/items/${root}/upload/`, { body }) | ||
24 | + .json<any>(); | ||
25 | + | ||
26 | + stateMap.current[file.name] = response.item.id; | ||
27 | + fields.current = response.signed_url.fields; | ||
28 | + return response.signed_url.url; | ||
29 | + }, | ||
30 | + [api, root] | ||
31 | + ); | ||
32 | + | ||
33 | + const setObjectStatus = useCallback( | ||
34 | + async (info) => { | ||
35 | + if (info.file.status === "done") { | ||
36 | + const id = stateMap.current[info.file.name]; | ||
37 | + if (typeof id !== "undefined") { | ||
38 | + const body = new URLSearchParams(); | ||
39 | + body.set("item_id", id.toString()); | ||
40 | + await api.post(`/items/${id}/status/`, { body }); | ||
41 | + reload(); | ||
42 | + } | ||
43 | + } | ||
44 | + }, | ||
45 | + [api, reload] | ||
46 | + ); | ||
47 | + | ||
48 | + return ( | ||
49 | + <Dragger | ||
50 | + name="file" | ||
51 | + multiple={true} | ||
52 | + action={getS3Object} | ||
53 | + data={() => fields.current} | ||
54 | + onChange={setObjectStatus} | ||
55 | + style={{ padding: 40 }} | ||
56 | + > | ||
57 | + <p className="ant-upload-drag-icon"> | ||
58 | + <InboxOutlined /> | ||
59 | + </p> | ||
60 | + <p className="ant-upload-text"> | ||
61 | + 업로드할 파일을 선택하거나 드래그 하세요 | ||
62 | + </p> | ||
63 | + <p className="ant-upload-hint"></p> | ||
64 | + </Dragger> | ||
65 | + ); | ||
66 | +} |
frontend/src/file/useDownload.ts
0 → 100644
1 | +import { useApi } from "util/useApi"; | ||
2 | +import { useCallback } from "react"; | ||
3 | + | ||
4 | +function downloadURL(url: string, name: string) { | ||
5 | + const link = document.createElement("a"); | ||
6 | + link.setAttribute("download", name); | ||
7 | + link.href = url; | ||
8 | + link.click(); | ||
9 | +} | ||
10 | + | ||
11 | +export function useDownload() { | ||
12 | + const api = useApi(); | ||
13 | + const download = useCallback( | ||
14 | + async (id: number) => { | ||
15 | + const response = await api.get(`/items/${id}/`).json<any>(); | ||
16 | + const { signed_url, name } = response.data; | ||
17 | + downloadURL(signed_url, name); | ||
18 | + }, | ||
19 | + [api] | ||
20 | + ); | ||
21 | + return download; | ||
22 | +} |
frontend/src/file/useFileList.ts
0 → 100644
1 | +import { useState, useCallback, useEffect } from "react"; | ||
2 | +import ky from "ky"; | ||
3 | + | ||
4 | +interface FileListData extends FileItem { | ||
5 | + list: FileItem[]; | ||
6 | +} | ||
7 | + | ||
8 | +interface FileListResponse { | ||
9 | + data: FileListData; | ||
10 | +} | ||
11 | + | ||
12 | +export interface FileItem { | ||
13 | + is_folder: boolean; | ||
14 | + name: string; | ||
15 | + file_type: "folder" | "file"; | ||
16 | + path: string; | ||
17 | + parent: number; | ||
18 | + user_id: number; | ||
19 | + size: number; | ||
20 | + is_deleted: boolean; | ||
21 | + created_time: string | null; | ||
22 | + updated_time: string; | ||
23 | + status: boolean; | ||
24 | + id: number; | ||
25 | +} | ||
26 | + | ||
27 | +export function useFileList(id: string | number) { | ||
28 | + const [data, setData] = useState<FileListData | null>(null); | ||
29 | + | ||
30 | + const reload = useCallback(async () => { | ||
31 | + const response = await ky | ||
32 | + .get(`/items/${id}/children/`) | ||
33 | + .json<FileListResponse>(); | ||
34 | + | ||
35 | + setData(response.data); | ||
36 | + }, [id]); | ||
37 | + | ||
38 | + useEffect(() => { | ||
39 | + reload(); | ||
40 | + }, [reload]); | ||
41 | + | ||
42 | + return { data, reload }; | ||
43 | +} |
1 | -body { | 1 | +#root { |
2 | - margin: 0; | 2 | + height: 100%; |
3 | - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", | ||
4 | - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", | ||
5 | - sans-serif; | ||
6 | - -webkit-font-smoothing: antialiased; | ||
7 | - -moz-osx-font-smoothing: grayscale; | ||
8 | -} | ||
9 | - | ||
10 | -code { | ||
11 | - font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", | ||
12 | - monospace; | ||
13 | } | 3 | } | ... | ... |
1 | import React from "react"; | 1 | import React from "react"; |
2 | import ReactDOM from "react-dom"; | 2 | import ReactDOM from "react-dom"; |
3 | +import { BrowserRouter } from "react-router-dom"; | ||
3 | 4 | ||
5 | +import "antd/dist/antd.css"; | ||
4 | import "./index.css"; | 6 | import "./index.css"; |
5 | 7 | ||
6 | import { App } from "./App"; | 8 | import { App } from "./App"; |
... | @@ -8,9 +10,9 @@ import { App } from "./App"; | ... | @@ -8,9 +10,9 @@ import { App } from "./App"; |
8 | import * as serviceWorker from "./serviceWorker"; | 10 | import * as serviceWorker from "./serviceWorker"; |
9 | 11 | ||
10 | ReactDOM.render( | 12 | ReactDOM.render( |
11 | - <React.StrictMode> | 13 | + <BrowserRouter> |
12 | <App /> | 14 | <App /> |
13 | - </React.StrictMode>, | 15 | + </BrowserRouter>, |
14 | document.getElementById("root") | 16 | document.getElementById("root") |
15 | ); | 17 | ); |
16 | 18 | ... | ... |
frontend/src/layout/Page.module.scss
0 → 100644
1 | +.layout { | ||
2 | + height: 100%; | ||
3 | +} | ||
4 | + | ||
5 | +.header { | ||
6 | + display: flex; | ||
7 | + align-items: center; | ||
8 | + justify-content: space-between; | ||
9 | +} | ||
10 | + | ||
11 | +.content { | ||
12 | + background: #fff; | ||
13 | + padding: 25px 50px; | ||
14 | +} | ||
15 | + | ||
16 | +.logo { | ||
17 | + width: 120px; | ||
18 | + height: 31px; | ||
19 | + margin: 16px 24px 16px 0; | ||
20 | + float: left; | ||
21 | + display: flex; | ||
22 | + align-items: center; | ||
23 | + justify-content: center; | ||
24 | + color: white; | ||
25 | + font-size: 24px; | ||
26 | + font-weight: bold; | ||
27 | +} | ||
28 | + | ||
29 | +.user { | ||
30 | + display: flex; | ||
31 | + align-items: center; | ||
32 | + color: white; | ||
33 | + | ||
34 | + svg { | ||
35 | + width: 28px; | ||
36 | + height: 28px; | ||
37 | + } | ||
38 | + | ||
39 | + &:hover, | ||
40 | + &:active, | ||
41 | + &:focus { | ||
42 | + color: rgba(255, 255, 255, 0.65); | ||
43 | + } | ||
44 | +} | ||
45 | + | ||
46 | +.footer { | ||
47 | + text-align: center; | ||
48 | +} |
frontend/src/layout/Page.tsx
0 → 100644
1 | +import React, { useContext } from "react"; | ||
2 | +import { Layout, Popover, Button } from "antd"; | ||
3 | +import { UserOutlined } from "@ant-design/icons"; | ||
4 | +import { TokenContext } from "auth/useAuth"; | ||
5 | + | ||
6 | +import styles from "./Page.module.scss"; | ||
7 | + | ||
8 | +export function Page({ children }: { children: React.ReactNode }) { | ||
9 | + const { token, logout } = useContext(TokenContext); | ||
10 | + return ( | ||
11 | + <Layout className={styles.layout}> | ||
12 | + <Layout.Header className={styles.header}> | ||
13 | + <div className={styles.logo}>KHUDrive</div> | ||
14 | + <Popover | ||
15 | + content={ | ||
16 | + <div> | ||
17 | + {token?.user.name} | ||
18 | + <Button type="link" onClick={logout}> | ||
19 | + 로그아웃 | ||
20 | + </Button> | ||
21 | + </div> | ||
22 | + } | ||
23 | + trigger="click" | ||
24 | + > | ||
25 | + <Button type="text" className={styles.user}> | ||
26 | + <UserOutlined /> | ||
27 | + </Button> | ||
28 | + </Popover> | ||
29 | + </Layout.Header> | ||
30 | + <Layout.Content className={styles.content}>{children}</Layout.Content> | ||
31 | + <Layout.Footer className={styles.footer}> | ||
32 | + © 2020 Cloud Computing Team C | ||
33 | + </Layout.Footer> | ||
34 | + </Layout> | ||
35 | + ); | ||
36 | +} |
frontend/src/util/useApi.ts
0 → 100644
frontend/src/util/usePrevious.ts
0 → 100644
secrets.json
0 → 100644
1 | +{ | ||
2 | + "AWS_SESSION_TOKEN": "", | ||
3 | + "AWS_SECRET_ACCESS_KEY": "secret_key", | ||
4 | + "AWS_ACCESS_KEY_ID": "access_key", | ||
5 | + "AWS_REGION": "us-west-2", | ||
6 | + "AWS_STORAGE_BUCKET_NAME": "bucket", | ||
7 | + "AWS_ENDPOINT_URL": "http://localhost:39000" | ||
8 | +} | ||
... | \ No newline at end of file | ... | \ No newline at end of file |
-
Please register or login to post a comment