김재형

Merge branch 'feature/frontend'

Showing 48 changed files with 1297 additions and 106 deletions
/env
/docker
/.vscode
.DS_Store
__pycache__
\ No newline at end of file
......@@ -22,7 +22,7 @@ from django.conf import settings
import jwt
from django.http import HttpResponse, JsonResponse
from khudrive.settings import AWS_SESSION_TOKEN, AWS_SECRET_ACCESS_KEY, AWS_ACCESS_KEY_ID, AWS_REGION, \
AWS_STORAGE_BUCKET_NAME
AWS_STORAGE_BUCKET_NAME, AWS_ENDPOINT_URL
class UserViewSet(viewsets.ModelViewSet):
......@@ -51,6 +51,8 @@ class UserViewSet(viewsets.ModelViewSet):
root = Item(is_folder=True, name="root", file_type="folder", path="", user_id=user.int_id, size=0,
status=True)
root.save()
user.root_folder = root.item_id
user.save()
return Response({
'message': 'user created',
'int_id': user.int_id,
......@@ -94,7 +96,15 @@ class UserViewSet(viewsets.ModelViewSet):
exp = jwt.decode(access, settings.SECRET_KEY, algorithm='HS256')['exp']
token = {'access': access,
'refresh': refresh,
'exp': exp}
'exp': exp,
'user': {
'int_id': user.int_id,
'user_id': user.user_id,
'name': user.name,
'total_size': user.total_size,
'current_size': user.current_size,
'root_folder': user.root_folder
}}
return JsonResponse(
token,
status=status.HTTP_200_OK,
......@@ -173,11 +183,15 @@ class ItemViewSet(viewsets.ViewSet):
# url: items/11/
# 마지막 slash도 써주어야함
def get(self, request, pk):
s3 = boto3.client('s3',
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
aws_session_token=AWS_SESSION_TOKEN,
config=Config(signature_version='s3v4'))
s3 = boto3.client(
's3',
region_name=AWS_REGION,
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
aws_session_token=AWS_SESSION_TOKEN,
endpoint_url=AWS_ENDPOINT_URL or None,
config=Config(s3={'addressing_style': 'path'})
)
s3_bucket = AWS_STORAGE_BUCKET_NAME
item = Item.objects.filter(item_id=pk)
......@@ -239,29 +253,40 @@ class ItemViewSet(viewsets.ViewSet):
def move(self, request, pk):
if request.method == 'POST':
parent_id = request.POST.get('parent', '')
name = request.POST.get('name', '')
parent = get_object_or_None(Item, item_id=parent_id)
if parent != None and parent.is_folder == True:
child = get_object_or_None(Item, item_id=pk)
if child == None:
return Response({'message': 'item is not existed.'}, status=status.HTTP_204_NO_CONTENT)
child.parent = parent_id
child.save()
child = Item.objects.filter(item_id=pk)
child_data = serializers.serialize("json", child)
json_child = json.loads(child_data)
res = json_child[0]['fields']
res['id'] = pk
parent = Item.objects.filter(item_id=parent_id)
parent_data = serializers.serialize("json", parent)
json_parent = json.loads(parent_data)[0]['fields']
res['parentInfo'] = json_parent
return Response({'data': res}, status=status.HTTP_200_OK)
if parent == None:
return Response({'message': 'parent is not existed.'}, status=status.HTTP_200_OK)
if parent.is_folder == False:
return Response({'message': 'parent is not folder.'}, status=status.HTTP_200_OK)
return Response({'message': 'item is not existed.'}, status=status.HTTP_204_NO_CONTENT)
name = request.POST.get('name','')
child = get_object_or_None(Item, item_id=pk)
if child == None:
return Response({'message': 'item is not existed.'}, status=status.HTTP_204_NO_CONTENT)
if parent_id != '':
parent = get_object_or_None(Item, item_id=parent_id)
if parent == None:
return Response({'message': 'parent is not existed.'}, status=status.HTTP_200_OK)
if parent.is_folder == False:
return Response({'message': 'parent is not folder.'}, status=status.HTTP_200_OK)
if parent != None and parent.is_folder == True:
child.parent = parent_id
else:
parent_id = child.parent
if name != '':
child.name = name;
child.save()
child = Item.objects.filter(item_id = pk)
child_data = serializers.serialize("json", child)
json_child = json.loads(child_data)
res = json_child[0]['fields']
res['id'] = pk
parent = Item.objects.filter(item_id = parent_id)
parent_data = serializers.serialize("json", parent)
json_parent = json.loads(parent_data)[0]['fields']
res['parentInfo'] = json_parent
return Response({'data': res}, status=status.HTTP_200_OK)
@action(methods=['POST'], detail=True, permission_classes=[AllowAny], url_path='copy', url_name='copy')
def copy(self, request, pk):
......@@ -308,7 +333,7 @@ class ItemViewSet(viewsets.ViewSet):
url_path='children', url_name='children')
def children(self, request, pk):
if request.method == 'GET':
children = Item.objects.filter(parent=pk, is_deleted=False)
children = Item.objects.filter(parent=pk, is_deleted=False, status=True)
children_data = serializers.serialize("json", children)
json_children = json.loads(children_data)
parent = Item.objects.filter(item_id=pk) # item
......@@ -359,7 +384,15 @@ class ItemViewSet(viewsets.ViewSet):
url_path='upload', url_name='upload')
def upload(self, request, pk):
if request.method == 'POST':
s3 = boto3.client('s3')
s3 = boto3.client(
's3',
region_name=AWS_REGION,
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
aws_session_token=AWS_SESSION_TOKEN,
endpoint_url=AWS_ENDPOINT_URL or None,
config=Config(s3={'addressing_style': 'path'})
)
s3_bucket = AWS_STORAGE_BUCKET_NAME
# 파일 객체 생성
......@@ -378,6 +411,7 @@ class ItemViewSet(viewsets.ViewSet):
{
"acl": "private",
"Content-Type": file_type,
"Content-Disposition": "attachment",
'region': AWS_REGION,
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
'x-amz-date': date_long
......@@ -385,18 +419,26 @@ class ItemViewSet(viewsets.ViewSet):
[
{"acl": "private"},
{"Content-Type": file_type},
{"Content-Disposition": "attachment"},
{'x-amz-algorithm': 'AWS4-HMAC-SHA256'},
{'x-amz-date': date_long}
],
3600
)
item = Item.objects.filter(item_id=upload_item.item_id)
item_data = serializers.serialize("json", item)
json_item = json.loads(item_data)
res = json_item[0]['fields']
res['id'] = json_item[0]['pk']
data = {
"signed_url": presigned_post,
'url': 'https://%s.s3.amazonaws.com/%s' % (s3_bucket, file_name)
'url': '%s/%s' % (presigned_post["url"], file_name),
'item': res
}
return Response({'presigned_post': presigned_post, 'proc_data': data}, status=status.HTTP_200_OK)
return Response(data, status=status.HTTP_200_OK)
# url: /status/
@action(methods=['POST'], detail=True, permission_classes=[AllowAny],
......
version: "3"
services:
postgres:
image: "postgres:alpine"
environment:
- POSTGRES_USER=khudrive
- POSTGRES_PASSWORD=4REPwb7y4CLtQaTv4PNeWRJeGLbHXn
- POSTGRES_DB=khudrive
ports:
- "35432:5432"
volumes:
- ./docker/postgres:/var/lib/postgresql/data/
minio:
image: "minio/minio"
entrypoint: sh
command: -c "mkdir -p /data/bucket && /usr/bin/minio server /data"
environment:
- MINIO_ACCESS_KEY=access_key
- MINIO_SECRET_KEY=secret_key
ports:
- "39000:9000"
volumes:
- ./docker/minio:/data
\ No newline at end of file
......@@ -88,11 +88,11 @@ DATABASES = {
# }
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'drive',
'USER': 'jooheekwon',
'PASSWORD': 'victoriawngml77',
'NAME': 'khudrive',
'USER': 'khudrive',
'PASSWORD': '4REPwb7y4CLtQaTv4PNeWRJeGLbHXn',
'HOST': 'localhost',
'PORT': '',
'PORT': '35432',
}
}
......
asgiref==3.2.7
boto3==1.14.2
botocore==1.17.2
cffi==1.14.0
cryptography==2.9.2
Django==3.0.6
django-annoying==0.10.6
djangorestframework==3.11.0
docutils==0.15.2
jmespath==0.10.0
psycopg2==2.8.5
pycparser==2.20
PyJWT==1.7.1
python-dateutil==2.8.1
pytz==2020.1
s3transfer==0.3.3
six==1.15.0
sqlparse==0.3.1
urllib3==1.25.9
......
......@@ -21,6 +21,7 @@
"react/display-name": "off",
"react/prop-types": "off",
"no-empty": ["warn", { "allowEmptyCatch": true }],
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-member-accessibility": "off",
"@typescript-eslint/interface-name-prefix": "off",
......
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/backend/env
/frontend/node_modules
/frontend/.pnp
/node_modules
/.pnp
.pnp.js
# testing
/frontend/coverage
/coverage
# production
/frontend/build
# database
/backend/db.sqlite3
/build
# misc
.DS_Store
......@@ -21,7 +17,6 @@
.env.development.local
.env.test.local
.env.production.local
__pycache__
npm-debug.log*
yarn-debug.log*
......
This diff is collapsed. Click to expand it.
......@@ -4,37 +4,41 @@
"description": "Dropbox alternative cloud file service",
"private": true,
"dependencies": {
"@ant-design/icons": "^4.2.1",
"antd": "^4.3.3",
"classnames": "^2.2.6",
"ky": "^0.19.1",
"filesize": "^6.1.0",
"ky": "^0.20.0",
"miragejs": "^0.1.40",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.1.2"
"react-router-dom": "^5.2.0"
},
"devDependencies": {
"@hot-loader/react-dom": "^16.13.0",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^10.0.4",
"@testing-library/user-event": "^10.1.2",
"@testing-library/jest-dom": "^5.9.0",
"@testing-library/react": "^10.2.0",
"@testing-library/user-event": "^11.2.0",
"@types/classnames": "^2.2.10",
"@types/jest": "^25.2.1",
"@types/jest": "^25.2.3",
"@types/node": "12",
"@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.5",
"@typescript-eslint/eslint-plugin": "^2.31.0",
"@typescript-eslint/parser": "^2.31.0",
"customize-cra": "0.9.1",
"@typescript-eslint/eslint-plugin": "^2.34.0",
"@typescript-eslint/parser": "^2.34.0",
"customize-cra": "1.0.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-jest": "^23.10.0",
"eslint-plugin-jest": "^23.13.2",
"husky": "^4.2.5",
"lint-staged": "^10.2.2",
"lint-staged": "^10.2.9",
"node-sass": "^4.14.1",
"prettier": "^2.0.5",
"react-app-rewired": "^2.1.6",
"react-hot-loader": "^4.12.21",
"react-scripts": "3.4.1",
"typescript": "^3.8.3",
"webpack-bundle-analyzer": "^3.7.0"
"typescript": "^3.9.5",
"webpack-bundle-analyzer": "^3.8.0"
},
"scripts": {
"start": "react-app-rewired start",
......@@ -63,5 +67,6 @@
},
"lint-staged": {
"*.{js,ts,tsx,json,css,scss}": "prettier --write"
}
},
"proxy": "http://localhost:8000"
}
......
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>
......@@ -2,42 +2,21 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<meta name="description" content="KHUDrive" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest">
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>KHUDrive</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
......
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M300 6180 c-105 -20 -218 -112 -268 -218 l-27 -57 0 -2410 0 -2410
29 -58 c50 -98 123 -163 223 -196 l63 -21 3197 2 3198 3 65 31 c80 38 149 105
187 182 l28 57 0 2040 0 2040 -33 67 c-48 99 -134 169 -242 198 -34 9 -494 12
-1930 14 l-1885 1 -65 31 c-96 46 -150 109 -240 279 -70 132 -165 287 -194
317 -46 47 -112 86 -175 103 -38 10 -247 13 -972 12 -508 0 -940 -3 -959 -7z"/>
</g>
</svg>
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
import React from "react";
import { Switch, Route, Redirect } from "react-router-dom";
import { Login } from "auth/Login";
import { Signup } from "auth/Signup";
import { useAuth, TokenContext } from "auth/useAuth";
import { Page } from "layout/Page";
import { FileList } from "file/FileList";
export function App() {
return <div>Hello World!</div>;
const token = useAuth();
const root = token?.token?.user.rootFolder;
return (
<Switch>
<Route path="/login">
<Login login={token.login} />
</Route>
<Route path="/signup">
<Signup />
</Route>
<Route>
{token.token !== null ? (
<TokenContext.Provider value={token}>
<Page>
<Switch>
<Route path="/folder/:id">
<FileList />
</Route>
<Route>
<Redirect to={`/folder/${root}`} />
</Route>
</Switch>
</Page>
</TokenContext.Provider>
) : (
<Redirect to="/login" />
)}
</Route>
</Switch>
);
}
......
.layout {
height: 100%;
align-items: center;
justify-content: center;
}
.content {
width: 640px;
flex-grow: 0;
background: #fff;
padding: 80px 50px 50px;
}
#components-form-demo-normal-login .login-form-forgot {
float: right;
}
#components-form-demo-normal-login .ant-col-rtl .login-form-forgot {
float: left;
}
#components-form-demo-normal-login .login-form-button {
width: 100%;
}
import React, { useCallback, useState } from "react";
import { Form, Input, Button, Checkbox, Layout } from "antd";
import { UserOutlined, LockOutlined } from "@ant-design/icons";
import { useHistory, Link } from "react-router-dom";
import styles from "./Login.module.scss";
export type LoginProps = {
login: (
username: string,
password: string,
remember: boolean
) => Promise<void>;
};
export function Login({ login }: LoginProps) {
const [error, setError] = useState<boolean>(false);
const history = useHistory();
const handleLogin = useCallback(
async ({ username, password, remember }) => {
setError(false);
try {
await login(username, password, remember);
history.push("/");
} catch {
setError(true);
}
},
[login, history]
);
return (
<Layout className={styles.layout}>
<Layout.Content className={styles.content}>
<Form
name="login"
initialValues={{ remember: true }}
onFinish={handleLogin}
>
<Form.Item
name="username"
rules={[{ required: true, message: "아이디를 입력하세요" }]}
{...(error && {
validateStatus: "error",
})}
>
<Input prefix={<UserOutlined />} placeholder="아이디" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "비밀번호를 입력하세요" }]}
{...(error && {
validateStatus: "error",
help: "로그인에 실패했습니다",
})}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder="비밀번호"
/>
</Form.Item>
<Form.Item>
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox>자동 로그인</Checkbox>
</Form.Item>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
로그인
</Button>
<Link to="/signup" style={{ marginLeft: 24 }}>
회원가입
</Link>
</Form.Item>
</Form>
</Layout.Content>
</Layout>
);
}
import React, { useCallback, useState } from "react";
import { Form, Input, Button, Layout, message } from "antd";
import { UserOutlined, LockOutlined, TagOutlined } from "@ant-design/icons";
import { useHistory } from "react-router-dom";
import styles from "./Login.module.scss";
import ky from "ky";
export function Signup() {
const [error, setError] = useState<boolean>(false);
const [check, setCheck] = useState<boolean>(false);
const history = useHistory();
const handleSignup = useCallback(
async ({ user_id, password, password_check, name }) => {
if (password !== password_check) {
return setCheck(true);
} else {
setCheck(false);
}
setError(false);
try {
const body = new URLSearchParams();
body.set("user_id", user_id);
body.set("password", password);
body.set("name", name);
await ky.post("/users/signup/", { body });
message.success("회원가입이 완료되었습니다");
history.push("/login");
} catch {
setError(true);
}
},
[history]
);
return (
<Layout className={styles.layout}>
<Layout.Content className={styles.content}>
<Form name="signup" onFinish={handleSignup}>
<Form.Item
name="user_id"
rules={[{ required: true, message: "아이디를 입력하세요" }]}
{...(error && {
validateStatus: "error",
})}
>
<Input prefix={<UserOutlined />} placeholder="아이디" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "비밀번호를 입력하세요" }]}
{...(error && {
validateStatus: "error",
help: "로그인에 실패했습니다",
})}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder="비밀번호"
/>
</Form.Item>
<Form.Item
name="password_check"
rules={[
{ required: true, message: "비밀번호를 한번 더 입력하세요" },
]}
{...(error && {
validateStatus: "error",
help: "로그인에 실패했습니다",
})}
{...(check && {
validateStatus: "error",
help: "비밀번호가 일치하지 않습니다",
})}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder="비밀번호 확인"
/>
</Form.Item>
<Form.Item
name="name"
rules={[{ required: true, message: "이름을 입력하세요" }]}
{...(error && {
validateStatus: "error",
})}
>
<Input prefix={<TagOutlined />} placeholder="이름" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
회원 가입
</Button>
</Form.Item>
</Form>
</Layout.Content>
</Layout>
);
}
import React, { useState, useCallback } from "react";
import ky from "ky";
interface LoginResponse {
access: string;
refresh: string;
exp: number;
user: {
int_id: number;
user_id: string;
name: string;
total_size: number;
current_size: number;
root_folder: number;
};
}
interface Token {
accessToken: string;
refreshToken: string;
expiration: Date;
user: {
id: number;
username: string;
name: string;
totalSize: number;
currentSize: number;
rootFolder: number;
};
}
export const TokenContext = React.createContext<ReturnType<typeof useAuth>>(
{} as any
);
export function useAuth() {
const [token, setToken] = useState<Token | null>(() => {
const item = localStorage.getItem("token");
if (item) {
const token = JSON.parse(item);
token.expiration = new Date(token.expiration);
return token;
}
return null;
});
const login = useCallback(
async (username: string, password: string, remember: boolean) => {
const body = new URLSearchParams();
body.set("user_id", username);
body.set("password", password);
const response = await ky
.post("/users/login/", { body })
.json<LoginResponse>();
const token = {
accessToken: response.access,
refreshToken: response.refresh,
expiration: new Date(response.exp * 1000),
user: {
id: response.user.int_id,
username: response.user.user_id,
name: response.user.name,
totalSize: response.user.total_size,
currentSize: response.user.current_size,
rootFolder: response.user.root_folder,
},
};
setToken(token);
if (remember) {
localStorage.setItem("token", JSON.stringify(token));
}
},
[]
);
const logout = useCallback(() => setToken(null), []);
return { token, login, logout };
}
import React, { useState } from "react";
import { Button, Input } from "antd";
export type CreateFolderPopoverProps = {
onCreate: (name: string) => void;
onCancel?: () => void;
};
export function CreateFolderPopover({
onCreate,
onCancel,
}: CreateFolderPopoverProps) {
const [name, setName] = useState<string>("");
return (
<div>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="이름"
style={{ marginBottom: 10 }}
/>
<div className="ant-popover-buttons">
<Button size="small" onClick={onCancel}>
취소
</Button>
<Button type="primary" size="small" onClick={() => onCreate(name)}>
생성
</Button>
</div>
</div>
);
}
import React, { useState, Fragment } from "react";
import { Popconfirm, Popover, Button, message } from "antd";
import { FileItem } from "./useFileList";
import styles from "./FileItemActions.module.scss";
import { FileListPopover } from "./FileListPopover";
import { FileRenamePopover } from "./FileRenamePopover";
export type FileItemActionsProps = {
item: FileItem;
onRename: (id: number, name: string) => void;
onMove: (id: number, to: number) => void;
onCopy: (id: number, to: number) => void;
onDelete: (id: number) => void;
};
export function FileItemActions({
item,
onRename,
onMove,
onCopy,
onDelete,
}: FileItemActionsProps) {
const [rename, setRename] = useState<boolean>(false);
const [move, setMove] = useState<boolean>(false);
const [copy, setCopy] = useState<boolean>(false);
return (
<div className={styles.actions}>
<Popover
title="변경할 이름을 입력하세요"
content={
<FileRenamePopover
name={item.name}
onRename={(name: string) => {
if (name === item.name) {
return message.error("동일한 이름으로는 변경할 수 없습니다");
}
if (!name) {
return message.error("변경할 이름을 입력하세요");
}
onRename(item.id, name);
setRename(false);
}}
onCancel={() => setRename(false)}
/>
}
trigger="click"
visible={rename}
onVisibleChange={setRename}
>
<Button type="link" size="small">
이름 변경
</Button>
</Popover>
{!item.is_folder && (
<Button type="link" size="small">
공유
</Button>
)}
<Popover
title="이동할 폴더를 선택하세요"
content={
<FileListPopover
root={item.parent}
onSelect={(to: number) => {
if (to === item.parent) {
return message.error("같은 폴더로는 이동할 수 없습니다");
}
onMove(item.id, to);
setMove(false);
}}
onCancel={() => setMove(false)}
/>
}
trigger="click"
visible={move}
onVisibleChange={setMove}
>
<Button type="link" size="small">
이동
</Button>
</Popover>
{!item.is_folder && (
<Popover
title="복사할 폴더를 선택하세요"
content={
<FileListPopover
root={item.parent}
onSelect={(to: number) => {
onCopy(item.id, to);
setCopy(false);
}}
onCancel={() => setCopy(false)}
/>
}
trigger="click"
visible={copy}
onVisibleChange={setCopy}
>
<Button type="link" size="small">
복사
</Button>
</Popover>
)}
{!item.is_folder && (
<Popconfirm
title="정말로 삭제하시겠습니까?"
onConfirm={() => onDelete(item.id)}
okText="삭제"
cancelText="취소"
>
<Button type="link" size="small">
삭제
</Button>
</Popconfirm>
)}
</div>
);
}
.header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
import React, { useCallback, useState, useContext } from "react";
import { Table, message, Button, Popover } from "antd";
import { ColumnsType } from "antd/lib/table";
import filesize from "filesize";
import { useParams } from "react-router-dom";
import { useFileList, FileItem } from "./useFileList";
import { useApi } from "util/useApi";
import { FileListItem } from "./FileListItem";
import { FileItemActions } from "./FileItemActions";
import styles from "./FileList.module.scss";
import { FileUploadPopover } from "./FileUploadPopover";
import { CreateFolderPopover } from "./CreateFolderPopover";
import { TokenContext } from "auth/useAuth";
export function FileList() {
const id = useParams<{ id: string }>().id;
const { data, reload } = useFileList(id);
const [upload, setUpload] = useState<boolean>(false);
const [createFolder, setCreateFolder] = useState<boolean>(false);
const { token } = useContext(TokenContext);
const userId = token?.user.id || "";
const api = useApi();
const handleCreateFolder = useCallback(
async (id: number, name: string) => {
try {
const body = new URLSearchParams();
body.set("name", name);
await api.post(`/items/${id}/children/`, {
searchParams: {
user_id: userId,
},
body,
});
await reload();
message.info("폴더가 생성되었습니다");
} catch {
message.error("폴더 생성에 실패했습니다");
}
},
[api, reload, userId]
);
const handleRename = useCallback(
async (id: number, name: string) => {
try {
const body = new URLSearchParams();
body.set("name", name);
await api.post(`/items/${id}/move/`, { body });
await reload();
message.info("이름이 변경되었습니다");
} catch {
message.error("이름 변경에 실패했습니다");
}
},
[api, reload]
);
const handleMove = useCallback(
async (id: number, to: number) => {
try {
const body = new URLSearchParams();
body.set("parent", to.toString(10));
await api.post(`/items/${id}/move/`, { body });
await reload();
message.info("이동되었습니다");
} catch {
message.error("파일 이동에 실패했습니다");
}
},
[api, reload]
);
const handleCopy = useCallback(
async (id: number, to: number) => {
try {
const body = new URLSearchParams();
body.set("parent", to.toString(10));
await api.post(`/items/${id}/copy/`, { body });
await reload();
message.info("복사되었습니다");
} catch {
message.error("파일 복사에 실패했습니다");
}
},
[api, reload]
);
const handleDelete = useCallback(
async (id: number) => {
try {
await api.delete(`/items/${id}/`);
await reload();
message.info("삭제되었습니다");
} catch {
message.error("파일 삭제에 실패했습니다");
}
},
[api, reload]
);
if (!data) {
return null;
}
const list = [...data.list].sort((itemA, itemB) =>
itemA.is_folder === itemB.is_folder ? 0 : itemA.is_folder ? -1 : 1
);
if (data.parent !== null) {
list.unshift(({
id: data.parent,
is_folder: true,
name: "..",
file_type: "folder",
} as unknown) as FileItem);
}
return (
<div>
<div className={styles.header}>
<div>{data.parent !== null && <h3>{data.name}</h3>}</div>
<div>
<Popover
content={<FileUploadPopover root={data.id} reload={reload} />}
trigger="click"
visible={upload}
onVisibleChange={setUpload}
>
<Button type="link" size="small">
파일 업로드
</Button>
</Popover>
<Popover
title="폴더 이름을 입력하세요"
content={
<CreateFolderPopover
onCreate={(name: string) => {
if (!name) {
return message.error("폴더 이름을 입력하세요");
}
handleCreateFolder(data.id, name);
setCreateFolder(false);
}}
onCancel={() => setCreateFolder(false)}
/>
}
trigger="click"
visible={createFolder}
onVisibleChange={setCreateFolder}
>
<Button type="link" size="small">
새 폴더
</Button>
</Popover>
</div>
</div>
<Table
rowKey="id"
columns={getColumns({
handleRename,
handleMove,
handleCopy,
handleDelete,
})}
dataSource={list}
pagination={false}
locale={{
emptyText: "파일이 없습니다",
}}
/>
</div>
);
}
type GetColumnsParams = {
handleRename: (id: number, name: string) => void;
handleMove: (id: number, to: number) => void;
handleCopy: (id: number, to: number) => void;
handleDelete: (id: number) => void;
};
function getColumns({
handleRename,
handleMove,
handleCopy,
handleDelete,
}: GetColumnsParams): ColumnsType<FileItem> {
return [
{
title: "이름",
key: "name",
dataIndex: "name",
render: (_name: string, item) => <FileListItem item={item} />,
},
{
title: "크기",
key: "size",
dataIndex: "size",
width: 120,
render: (bytes: number, item) =>
item.is_folder ? "-" : filesize(bytes, { round: 0 }),
},
{
title: "",
key: "action",
dataIndex: "",
width: 300,
render: (__: any, item) => (
<FileItemActions
item={item}
onRename={handleRename}
onMove={handleMove}
onCopy={handleCopy}
onDelete={handleDelete}
/>
),
},
];
}
import React from "react";
import { FileItem } from "./useFileList";
import { Link } from "react-router-dom";
import { Button } from "antd";
import { FolderFilled, FileFilled } from "@ant-design/icons";
import { useDownload } from "./useDownload";
export function FileListItem({ item }: { item: FileItem }) {
const download = useDownload();
return item.is_folder ? (
<Link
className="ant-btn ant-btn-link ant-btn-sm"
to={`/folder/${item.id}`}
style={{ padding: 0, color: "#001529" }}
>
<FolderFilled /> <span>{item.name}</span>
</Link>
) : (
<Button
type="link"
size="small"
onClick={() => download(item.id)}
style={{ padding: 0, color: "#001529" }}
>
<FileFilled /> {item.name}
</Button>
);
}
.list {
list-style: none;
padding-left: 0;
}
import React, { useState } from "react";
import { useFileList } from "./useFileList";
import { Button } from "antd";
import styles from "./FileListPopover.module.scss";
export type FileListPopoverProps = {
root: number;
onSelect: (id: number) => void;
onCancel?: () => void;
};
export function FileListPopover({
root,
onSelect,
onCancel,
}: FileListPopoverProps) {
const [id, setId] = useState<number>(root);
const { data } = useFileList(id);
if (!data) {
return null;
}
const list = data.list
.filter((item) => item.is_folder)
.map((item) => ({ id: item.id, name: item.name }));
if (data.parent !== null) {
list.unshift({ id: data.parent, name: ".." });
}
return (
<div>
<div>{data.name}</div>
<ul className={styles.list}>
{list.map((item) => (
<li key={item.id}>
<Button type="link" size="small" onClick={() => setId(item.id)}>
{item.name}
</Button>
</li>
))}
</ul>
<div className="ant-popover-buttons">
<Button size="small" onClick={onCancel}>
취소
</Button>
<Button type="primary" size="small" onClick={() => onSelect(id)}>
선택
</Button>
</div>
</div>
);
}
import React, { useState } from "react";
import { Button, Input } from "antd";
export type FileRenamePopoverProps = {
name: string;
onRename: (name: string) => void;
onCancel?: () => void;
};
export function FileRenamePopover({
name: oldName,
onRename,
onCancel,
}: FileRenamePopoverProps) {
const [name, setName] = useState<string>(oldName);
return (
<div>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="이름"
style={{ marginBottom: 10 }}
/>
<div className="ant-popover-buttons">
<Button size="small" onClick={onCancel}>
취소
</Button>
<Button type="primary" size="small" onClick={() => onRename(name)}>
변경
</Button>
</div>
</div>
);
}
import React, { useCallback, useRef } from "react";
import Dragger from "antd/lib/upload/Dragger";
import { InboxOutlined } from "@ant-design/icons";
import { useApi } from "util/useApi";
export type FileUploadPopoverProps = {
root: number;
reload: () => void;
};
export function FileUploadPopover({ root, reload }: FileUploadPopoverProps) {
const api = useApi();
const fields = useRef<any>();
const stateMap = useRef<Record<string, number>>({});
const getS3Object = useCallback(
async (file: File) => {
const body = new URLSearchParams();
body.set("name", file.name);
body.set("size", file.size.toString());
const response = await api
.post(`/items/${root}/upload/`, { body })
.json<any>();
stateMap.current[file.name] = response.item.id;
fields.current = response.signed_url.fields;
return response.signed_url.url;
},
[api, root]
);
const setObjectStatus = useCallback(
async (info) => {
if (info.file.status === "done") {
const id = stateMap.current[info.file.name];
if (typeof id !== "undefined") {
const body = new URLSearchParams();
body.set("item_id", id.toString());
await api.post(`/items/${id}/status/`, { body });
reload();
}
}
},
[api, reload]
);
return (
<Dragger
name="file"
multiple={true}
action={getS3Object}
data={() => fields.current}
onChange={setObjectStatus}
style={{ padding: 40 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">
업로드할 파일을 선택하거나 드래그 하세요
</p>
<p className="ant-upload-hint"></p>
</Dragger>
);
}
import { useApi } from "util/useApi";
import { useCallback } from "react";
function downloadURL(url: string, name: string) {
const link = document.createElement("a");
link.setAttribute("download", name);
link.href = url;
link.click();
}
export function useDownload() {
const api = useApi();
const download = useCallback(
async (id: number) => {
const response = await api.get(`/items/${id}/`).json<any>();
const { signed_url, name } = response.data;
downloadURL(signed_url, name);
},
[api]
);
return download;
}
import { useState, useCallback, useEffect } from "react";
import ky from "ky";
interface FileListData extends FileItem {
list: FileItem[];
}
interface FileListResponse {
data: FileListData;
}
export interface FileItem {
is_folder: boolean;
name: string;
file_type: "folder" | "file";
path: string;
parent: number;
user_id: number;
size: number;
is_deleted: boolean;
created_time: string | null;
updated_time: string;
status: boolean;
id: number;
}
export function useFileList(id: string | number) {
const [data, setData] = useState<FileListData | null>(null);
const reload = useCallback(async () => {
const response = await ky
.get(`/items/${id}/children/`)
.json<FileListResponse>();
setData(response.data);
}, [id]);
useEffect(() => {
reload();
}, [reload]);
return { data, reload };
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
#root {
height: 100%;
}
......
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import "antd/dist/antd.css";
import "./index.css";
import { App } from "./App";
......@@ -8,9 +10,9 @@ import { App } from "./App";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App />
</React.StrictMode>,
</BrowserRouter>,
document.getElementById("root")
);
......
.layout {
height: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.content {
background: #fff;
padding: 25px 50px;
}
.logo {
width: 120px;
height: 31px;
margin: 16px 24px 16px 0;
float: left;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: bold;
}
.user {
display: flex;
align-items: center;
color: white;
svg {
width: 28px;
height: 28px;
}
&:hover,
&:active,
&:focus {
color: rgba(255, 255, 255, 0.65);
}
}
.footer {
text-align: center;
}
import React, { useContext } from "react";
import { Layout, Popover, Button } from "antd";
import { UserOutlined } from "@ant-design/icons";
import { TokenContext } from "auth/useAuth";
import styles from "./Page.module.scss";
export function Page({ children }: { children: React.ReactNode }) {
const { token, logout } = useContext(TokenContext);
return (
<Layout className={styles.layout}>
<Layout.Header className={styles.header}>
<div className={styles.logo}>KHUDrive</div>
<Popover
content={
<div>
{token?.user.name}
<Button type="link" onClick={logout}>
로그아웃
</Button>
</div>
}
trigger="click"
>
<Button type="text" className={styles.user}>
<UserOutlined />
</Button>
</Popover>
</Layout.Header>
<Layout.Content className={styles.content}>{children}</Layout.Content>
<Layout.Footer className={styles.footer}>
© 2020 Cloud Computing Team C
</Layout.Footer>
</Layout>
);
}
import { useMemo } from "react";
import ky from "ky";
// TODO: Implement Auth
export function useApi() {
return useMemo(() => {
return ky.extend({
hooks: {
beforeRequest: [],
},
});
}, []);
}
import { useRef, useEffect } from "react";
export function usePrevious<T>(value: T) {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current as T;
}
{
"AWS_SESSION_TOKEN": "",
"AWS_SECRET_ACCESS_KEY": "secret_key",
"AWS_ACCESS_KEY_ID": "access_key",
"AWS_REGION": "us-west-2",
"AWS_STORAGE_BUCKET_NAME": "bucket",
"AWS_ENDPOINT_URL": "http://localhost:39000"
}
\ No newline at end of file