yaml-admin-front 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "yaml-admin-front",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "description": "React components for yaml-admin front (library)",
6
6
  "main": "src/index.js",
7
- "scripts": {},
7
+ "scripts": {
8
+ "publish": "npm publish -w yaml-admin-front --access public"
9
+ },
8
10
  "dependencies": {
9
11
  "@iconify/react": "^6.0.0",
10
12
  "axios": "^1.11.0",
package/src/YMLAdmin.jsx CHANGED
@@ -1,27 +1,61 @@
1
- import { Admin, Resource, ListGuesser } from "react-admin";
1
+ import { Admin, Resource, ListGuesser, CreateBase, fetchUtils, CustomRoutes } from "react-admin";
2
2
  import jsonServerProvider from "ra-data-json-server";
3
+ import { Route } from "react-router-dom";
3
4
  import YAML from 'yaml';
4
- import LoginPage from './login/LoginPage';
5
5
  import MyLayout from './layout/MyLayout'
6
+ import DynamicList from './section/DynamicList';
7
+ import DynamicCreate from './section/DynamicCreate';
8
+ import DynamicEdit from './section/DynamicEdit';
9
+ import DynamicShow from './section/DynamicShow';
6
10
  import { useState, useEffect } from 'react';
7
11
  import { Icon } from '@iconify/react';
8
12
  import { AdminProvider } from './AdminContext';
9
13
  import authProvider from './login/authProvider';
10
- import { setApiHost} from './common/axios';
14
+ import { setApiHost } from './common/axios';
15
+ import fileUploader from './common/fileUploader';
11
16
 
12
- const API_HOST = import.meta.env.VITE_HOST_API || 'http://localhost:6911'
13
- const dataProvider = jsonServerProvider(API_HOST);
17
+ const httpClient = (url, options = {}) => {
18
+ if (!options.headers) {
19
+ options.headers = new Headers({ Accept: 'application/json' });
20
+ }
21
+ const token = localStorage.getItem('token');
22
+ options.headers.set('x-access-token', token);
23
+ return fetchUtils.fetchJson(url, options);
24
+ }
14
25
 
15
- const YMLAdmin = ({ adminYaml }) => {
26
+ const YMLAdmin = ({ adminYaml, i18nProvider, custom }) => {
16
27
  const [yml, setYml] = useState(null);
28
+ const [dataProvider, setDataProvider] = useState(null);
29
+
17
30
  useEffect(() => {
18
31
  const loadYamlFile = async () => {
19
32
  try {
20
33
  const json = YAML.parse(adminYaml);
21
34
  setYml(json);
22
- setApiHost(json['api-host'].uri);
35
+ const api_host = json['api-host'].uri;
36
+ const privateEntityMap = {}
37
+ Object.entries(json.entity).map(([key, val])=>{
38
+ val.fields.map((field)=>{
39
+ if(field.private) {
40
+ privateEntityMap[key] = {
41
+ ...privateEntityMap[key],
42
+ [field.name]: field.private
43
+ }
44
+ }
45
+ })
46
+ });
47
+
48
+ if(json.upload?.local) {
49
+ setDataProvider(fileUploader(jsonServerProvider(api_host, httpClient), true, privateEntityMap));
50
+ } else if(json.upload?.s3) {
51
+ setDataProvider(fileUploader(jsonServerProvider(api_host, httpClient), false, privateEntityMap));
52
+ } else {
53
+ setDataProvider(jsonServerProvider(api_host, httpClient));
54
+ }
55
+
56
+ setApiHost(api_host);
23
57
  } catch (error) {
24
- console.error('YAML 파일을 읽는 중 오류가 발생했습니다:', error);
58
+ console.error('YAML file load error', error);
25
59
  }
26
60
  };
27
61
 
@@ -29,26 +63,49 @@ const YMLAdmin = ({ adminYaml }) => {
29
63
  }, []);
30
64
 
31
65
  return (
32
- <AdminProvider initialYml={yml} width="1250px">
33
- <Admin
34
- layout={MyLayout}
35
- authProvider={authProvider}
36
- dataProvider={dataProvider}>
37
- {yml?.entity && Object.keys(yml.entity).map(name => {
38
- const entity = yml.entity[name];
39
- const IconComponent = entity?.icon
40
- ? () => <Icon icon={entity.icon} width="1.25rem" height="1.25rem" />
41
- : undefined;
42
- return (
43
- <Resource key={name} name={name}
44
- options={{ label: entity.label }}
45
- icon={IconComponent}
46
- list={ListGuesser} />
47
- )
48
- })}
49
-
50
- </Admin>
51
- </AdminProvider>
66
+ <>
67
+ {dataProvider && <AdminProvider initialYml={yml} width="1250px">
68
+ <Admin
69
+ dashboard={undefined}
70
+ layout={MyLayout}
71
+ authProvider={authProvider}
72
+ i18nProvider={i18nProvider}
73
+ dataProvider={dataProvider}>
74
+ {yml?.entity && Object.keys(yml.entity).map(name => {
75
+ const entity = yml.entity[name];
76
+ const IconComponent = entity?.icon
77
+ ? () => <Icon icon={entity.icon} width="1.25rem" height="1.25rem" />
78
+ : undefined;
79
+
80
+ if (entity.custom)
81
+ return <Resource key={name} name={name} options={{ label: entity.label }} icon={IconComponent}/>
82
+ else
83
+ return (
84
+ <Resource key={name} name={name}
85
+ options={{ label: entity.label }}
86
+ icon={IconComponent}
87
+ list={(props => <DynamicList {...props} custom={custom} />)}
88
+ create={(props => <DynamicCreate {...props} custom={custom} />)}
89
+ edit={(props => <DynamicEdit {...props} custom={custom} />)}
90
+ show={(props => <DynamicShow {...props} custom={custom} />)}
91
+ />
92
+ )
93
+
94
+ })}
95
+
96
+ {/* <CustomRoutes>
97
+ {yml?.entity && Object.keys(yml.entity).map(name => {
98
+ const entity = yml.entity[name];
99
+ if (entity.custom)
100
+ return (
101
+ <Route path={`/${name}`} element={customEntity(name, 'entire')} />
102
+ )
103
+ })}
104
+ </CustomRoutes> */}
105
+ </Admin>
106
+ </AdminProvider>
107
+ }
108
+ </>
52
109
  )
53
110
  };
54
111
 
@@ -4,6 +4,14 @@ import axios from 'axios';
4
4
 
5
5
  const axiosInstance = axios.create({});
6
6
 
7
+ export const updateToken = async (query) => {
8
+ if (query && query.token) {
9
+ sessionStorage.setItem('token', query.token);
10
+ localStorage.setItem('token', query.token);
11
+ axios.defaults.headers.common['x-access-token'] = `${query.token}`;
12
+ }
13
+ };
14
+
7
15
  export const setApiHost = (host) => {
8
16
  let base = host ?? import.meta.env.VITE_HOST_API ?? 'http://localhost:6911';
9
17
  if (base && !base.startsWith('http')) {
@@ -24,6 +32,7 @@ export default axiosInstance;
24
32
 
25
33
  // ----------------------------------------------------------------------
26
34
 
35
+
27
36
  export const fetcher = async (args) => {
28
37
  const [url, config] = Array.isArray(args) ? args : [args];
29
38
 
@@ -33,46 +42,193 @@ export const fetcher = async (args) => {
33
42
  };
34
43
 
35
44
  export const postFetcher = async (url, config, param) => {
45
+ if (!url.startsWith('http'))
46
+ url = `${import.meta.env.VITE_HOST_API}${url}`;
36
47
  const res = await axiosInstance.post(url, param, { ...config });
37
48
 
38
49
  return res.data;
39
50
  };
40
51
 
41
- export const putFetcher = async (url, config, param) => {
42
- const res = await axiosInstance.put(url, param, { ...config });
43
-
44
- return res.data;
45
- };
46
52
 
47
53
  export const uploadFile = (blob_path, file_name) => new Promise((resolve, reject) => {
48
- if(blob_path){
49
- const reader = new FileReader();
50
- reader.readAsArrayBuffer(blob_path);
51
- reader.onload = async () => {
52
- const ext = file_name.substring(file_name.lastIndexOf('.')+1, file_name.length);
53
- const res = await fetcher(`/api/media/url/put/${ext}`)
54
- fetch(res.upload_url, { method: "PUT",
55
- headers: {
56
- 'Content-Type': res.contentType
57
- },
58
- body:reader.result })
59
- .then(res2=>{
60
- resolve(res.key)
61
- });
62
- };
63
- reader.onerror = reject;
64
- } else {
65
- resolve(null);
54
+ const reader = new FileReader();
55
+ reader.readAsArrayBuffer(blob_path);
56
+ reader.onload = async () => {
57
+ const ext = file_name.substring(file_name.lastIndexOf('.') + 1, file_name.length);
58
+ const res = await fetcher(`/api/media/url/put/${ext}`)
59
+ fetch(res.upload_url, {
60
+ method: "PUT",
61
+ headers: {
62
+ 'Content-Type': res.contentType
63
+ },
64
+ body: reader.result
65
+ })
66
+ .then(res2 => {
67
+ resolve(res.key)
68
+ });
69
+ };
70
+ reader.onerror = reject;
71
+ });
72
+
73
+ export const uploadFileSecure = (blob_path, file_name) => new Promise((resolve, reject) => {
74
+ const reader = new FileReader();
75
+ reader.readAsArrayBuffer(blob_path);
76
+ reader.onload = async () => {
77
+ const ext = file_name.substring(file_name.lastIndexOf('.') + 1, file_name.length);
78
+ const res = await fetcher(`/api/media/url/secure/put/${ext}`)
79
+ fetch(res.upload_url, {
80
+ method: "PUT",
81
+ headers: {
82
+ 'Content-Type': res.contentType
83
+ },
84
+ body: reader.result
85
+ }).then(res2 => {
86
+ resolve(res.key)
87
+ });
88
+ };
89
+ reader.onerror = reject;
90
+ });
91
+
92
+ export const uploadFileSecureProgress = async (file, onProgress) => {
93
+ const CHUNK_SIZE = 50 * 1024 * 1024; // 진행상황 업데이트 위해 분할크기 축소
94
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
95
+ let uploadedSize = 0;
96
+
97
+ const ext = file.name.split('.').pop();
98
+
99
+ // uploadId
100
+ let initRes
101
+ try {
102
+ initRes = await fetcher(`/api/media/url/secure/init/${ext}`);
103
+ } catch (e) {
104
+ throw new Error("업로드 초기화 실패");
66
105
  }
106
+ const { uploadId, key } = initRes;
107
+
108
+ try {
109
+ const partRequests = Array.from({ length: totalChunks }, (_, i) =>
110
+ postFetcher(`/api/media/url/secure/part`, {}, { key, uploadId, partNumber: i + 1 })
111
+ );
112
+
113
+ // part uploadUrl
114
+ const partResponses = await Promise.all(partRequests);
115
+
116
+ /*
117
+ * uploadedParts = { PartNumber: chunk index, ETag: ETag }
118
+ */
119
+ const uploadedParts = [];
120
+
121
+ // 분할 업로드 진행상황 표시를 위해 순차 업로드
122
+ for (let partIndex = 0; partIndex < partResponses.length; partIndex++) {
123
+ const start = partIndex * CHUNK_SIZE;
124
+ const end = Math.min(start + CHUNK_SIZE, file.size);
125
+ const chunk = file.slice(start, end);
126
+
127
+ // eslint-disable-next-line no-await-in-loop
128
+ const uploadResponse = await fetchWithRetry(partResponses[partIndex].uploadUrl, { method: "PUT", body: chunk });
129
+ uploadedSize += chunk.size;
130
+ // 퍼센트 업데이트
131
+ onProgress(Math.round((uploadedSize / file.size) * 100));
132
+
133
+ const eTag = uploadResponse.headers.get("ETag");
134
+ uploadedParts.push({ PartNumber: partIndex + 1, ETag: eTag.replace(/"/g, '') });
135
+ }
136
+
137
+ // 분할 업로드 완료, 파일 병합 요청
138
+ await postFetcher(`/api/media/url/secure/complete`, {}, { key, uploadId, parts: uploadedParts })
139
+
140
+ return key;
141
+ } catch (e) {
142
+ const abort = await postFetcher(`/api/media/url/secure/abort`, {}, { key, uploadId });
143
+ if (abort && abort.r) {
144
+ throw new Error("파일 업로드 실패");
145
+ }
146
+ else {
147
+ throw new Error(`업로드 실패 key: ${key}`);
148
+ }
149
+ }
150
+ };
151
+
152
+ export const uploadFileWithBase64String = (base64string, file_name) => new Promise((resolve, reject) => {
153
+ const ext = file_name.substring(file_name.lastIndexOf('.') + 1, file_name.length);
154
+ fetcher(`/api/media/url/put/${ext}`).then(res => {
155
+ let byteString = base64string;
156
+ if (base64string.startsWith('data:'))
157
+ byteString = atob(base64string.split(',')[1]);
158
+ const ab = new ArrayBuffer(byteString.length);
159
+ const ia = new Uint8Array(ab);
160
+ for (let i = 0; i < byteString.length; i++) {
161
+ ia[i] = byteString.charCodeAt(i);
162
+ }
163
+ fetch(res.upload_url, {
164
+ method: "PUT",
165
+ headers: {
166
+ 'Content-Type': res.contentType
167
+ },
168
+ body: ab
169
+ })
170
+ .then(res2 => {
171
+ resolve(res.key)
172
+ });
173
+ })
67
174
  });
68
175
 
69
- export const convertFileToBase64 = file => new Promise((resolve, reject) => {
70
- if(file){
71
- const reader = new FileReader();
72
- reader.readAsDataURL(file);
73
- reader.onload = () => resolve(reader.result);
74
- reader.onerror = reject;
75
- } else {
76
- reject(new Error('no file'))
176
+ export const useGetFetcher = (path) => {
177
+ const url = path.startsWith('http') ? path : `${import.meta.env.VITE_HOST_API}${path}`
178
+ const { data, isLoading, error, isValidating } = useSWR(url, fetcher);
179
+
180
+ const memoizedValue = useMemo(
181
+ () => ({
182
+ data,
183
+ isLoading,
184
+ error,
185
+ isValidating
186
+ }),
187
+ [data, error, isLoading, isValidating]
188
+ );
189
+
190
+ return memoizedValue;
191
+ }
192
+
193
+ async function fetchWithRetry(url, options = {}, retries = 0) {
194
+ try {
195
+ return await fetch(url, options);
196
+ } catch (error) {
197
+ if (retries < 3) {
198
+ console.log(`요청 실패. 재시도${retries + 1}회 : ${url}`);
199
+ await new Promise(resolve => setTimeout(resolve, 1000));
200
+ return fetchWithRetry(url, options, retries + 1);
201
+ } else {
202
+ throw new Error("파일 업로드 실패");
203
+ }
77
204
  }
205
+ }
206
+ export const uploadFileLocal = (blob_path, file_name) => new Promise((resolve, reject) => {
207
+ const reader = new FileReader();
208
+ reader.readAsArrayBuffer(blob_path);
209
+ reader.onload = async () => {
210
+ const ext = file_name.substring(file_name.lastIndexOf('.') + 1, file_name.length);
211
+ const url = `/api/local/media/upload?ext=${ext}&name=${encodeURIComponent(file_name)}`
212
+ const res = await axiosInstance.put(url, reader.result, {
213
+ headers: { 'Content-Type': 'application/octet-stream' },
214
+ transformRequest: [(data) => data]
215
+ });
216
+ resolve(res.data.key)
217
+ };
218
+ reader.onerror = reject;
78
219
  });
220
+
221
+ export const uploadFileLocalSecure = (blob_path, file_name) => new Promise((resolve, reject) => {
222
+ const reader = new FileReader();
223
+ reader.readAsArrayBuffer(blob_path);
224
+ reader.onload = async () => {
225
+ const ext = file_name.substring(file_name.lastIndexOf('.') + 1, file_name.length);
226
+ const url = `/api/local/media/upload/secure?ext=${ext}&name=${encodeURIComponent(file_name)}`
227
+ const res = await axiosInstance.put(url, reader.result, {
228
+ headers: { 'Content-Type': 'application/octet-stream' },
229
+ transformRequest: [(data) => data]
230
+ });
231
+ resolve(res.data.key)
232
+ };
233
+ reader.onerror = reject;
234
+ });
@@ -0,0 +1,77 @@
1
+ import React from 'react';
2
+ import {
3
+ TextField, NumberField, ReferenceField, DateField, BooleanField,
4
+ ReferenceInput, AutocompleteInput, TextInput,
5
+ SelectInput, FunctionField, ImageInput, ImageField,
6
+ } from 'react-admin';
7
+ import { Avatar } from '@mui/material';
8
+ import ClickableImageField from '../component/ClickableImageField';
9
+ import SafeImageField from '../component/SafeImageField';
10
+
11
+ export const getFieldShow = (field, isList = false) => {
12
+ if (!field || field.type == 'password') return null;
13
+ if (field.type == 'string' || field.key){
14
+ return <TextField key={field.name} label={field.label} source={field.name} />
15
+ } else if (field.type == 'integer')
16
+ return <NumberField key={field.name} label={field.label} source={field.name} />
17
+ else if (field.type == 'select')
18
+ return <FunctionField key={field.name} label={field.label} source={field.name}
19
+ render={record => field.select_values.find(m => field.name == record[field.name])?.label} />
20
+ else if (field.type == 'reference')
21
+ return <ReferenceField key={field.name} link="show" label={field.label} source={field.name} reference={field.reference_entity}>
22
+ <TextField source={field.reference_name} />
23
+ </ReferenceField>
24
+ else if (field.type == 'date')
25
+ return <DateField key={field.name} label={field.label} source={field.name} />
26
+ else if (field.type == 'boolean')
27
+ return <BooleanField key={field.name} label={field.label} source={field.name} />
28
+ else if (field.type == 'objectId')
29
+ return <TextField key={field.name} label={field.label} source={field.name} />
30
+ else if (field.type == 'image') {
31
+ if(field.avatar)
32
+ return <FunctionField label={field.label} render={record =>
33
+ <Avatar alt="Natacha" src={record[field.name].image_preview}
34
+ sx={isList ? {width: 100, height: 100} : {width: 256, height: 256}}/>
35
+ } />
36
+ else
37
+ return <ClickableImageField key={field.name} label={field.label} source={field.name}
38
+ width={isList ? "100px" : "200px"} height={isList ? "100px" : "200px"}/>
39
+ }
40
+ else
41
+ return <TextField key={field.name} label={field.label} source={field.name} />
42
+ }
43
+
44
+ const required = (message = 'ra.validation.required') =>
45
+ value => value ? undefined : message;
46
+ const validateRequire = [required()];
47
+
48
+ export const getFieldEdit = (field, search = false) => {
49
+ if (!field)
50
+ return null;
51
+ const { type, autogenerate } = field
52
+ if (autogenerate && !search) return null
53
+ if (type == 'reference')
54
+ return <ReferenceInput key={field.name} label={field?.label} source={field.name} reference={field?.reference_entity} alwaysOn>
55
+ <AutocompleteInput sx={{ width: '300px' }} label={field?.label} optionText={field?.reference_name}
56
+ filterToQuery={(searchText) => ({ [field?.reference_name || 'q']: searchText })}
57
+ validate={field.required && !search && validateRequire}
58
+ />
59
+ </ReferenceInput>
60
+ else if (field?.type == 'select')
61
+ return <SelectInput key={field.name} label={field?.label} source={field.name} alwaysOn
62
+ choices={field?.select_values}
63
+ optionText="name" optionValue="label"
64
+ validate={field.required && !search && validateRequire}
65
+ />
66
+ else if (field?.type == 'image') {
67
+ return <ImageInput key={field.name} source={field.name} label={field.label} accept="image/*" placeholder={<p>{field.label}</p>}
68
+ validate={field.required && !search && validateRequire}>
69
+ <SafeImageField source={'src'} title={'title'} />
70
+ </ImageInput>
71
+ }
72
+ else {
73
+ return <TextInput key={field.name} label={field?.label} source={field.name} alwaysOn
74
+ validate={field.required && !search && validateRequire}
75
+ />
76
+ }
77
+ }
@@ -0,0 +1,81 @@
1
+ import { uploadFile, uploadFileLocal, uploadFileLocalSecure, uploadFileSecure } from '../common/axios'
2
+
3
+ /**
4
+ * react-admin v5 uses a method-based dataProvider (getList/getOne/create/update/...).
5
+ * This wrapper intercepts create/update to upload any fields containing `rawFile`,
6
+ * replaces them with `{ src, title }`, and then delegates to the original provider.
7
+ */
8
+ const fileUploader = (provider, isLocal = false, privateEntityMap = {}) => {
9
+ const isPlainObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
10
+ const replaceFileField = async (entity_name, filed_name, value) => {
11
+ if (value && value.rawFile && value.title) {
12
+ let key
13
+ let isSecure = false
14
+ if(privateEntityMap[entity_name] && privateEntityMap[entity_name][filed_name]) {
15
+ isSecure = true
16
+ }
17
+
18
+ if(isLocal) {
19
+ if(isSecure) {
20
+ key = await uploadFileLocalSecure(value.rawFile, value.title);
21
+ } else {
22
+ key = await uploadFileLocal(value.rawFile, value.title);
23
+ }
24
+ } else {
25
+ if(isSecure) {
26
+ key = await uploadFileSecure(value.rawFile, value.title);
27
+ } else {
28
+ key = await uploadFile(value.rawFile, value.title);
29
+ }
30
+ }
31
+
32
+ if (key) {
33
+ const next = { ...value };
34
+ next.src = key;
35
+ delete next.rawFile;
36
+ return next;
37
+ }
38
+ }
39
+ return value;
40
+ };
41
+
42
+ const deepProcessData = async (entity_name, data) => {
43
+ if (Array.isArray(data)) {
44
+ const processed = await Promise.all(data.map(item => deepProcessData(entity_name, item)));
45
+ return processed;
46
+ }
47
+ if (!isPlainObject(data)) return data;
48
+
49
+ const entries = await Promise.all(Object.entries(data).map(async ([key, val]) => {
50
+ if (val && val.rawFile) {
51
+ const replaced = await replaceFileField(entity_name, key, val);
52
+ return [key, replaced];
53
+ }
54
+ if (Array.isArray(val) || isPlainObject(val)) {
55
+ const nested = await deepProcessData(entity_name, val);
56
+ return [key, nested];
57
+ }
58
+ return [key, val];
59
+ }));
60
+
61
+ return Object.fromEntries(entries);
62
+ };
63
+
64
+ return {
65
+ ...provider,
66
+ async create(resource, params) {
67
+ const nextData = await deepProcessData(resource, params?.data ?? {});
68
+ return provider.create(resource, { ...params, data: nextData });
69
+ },
70
+ async update(resource, params) {
71
+ try {
72
+ const nextData = await deepProcessData(resource, params?.data ?? {});
73
+ return provider.update(resource, { ...params, data: nextData });
74
+ } catch (e) {
75
+ console.error('update error', e)
76
+ }
77
+ },
78
+ };
79
+ };
80
+
81
+ export default fileUploader;