@fuzionx/framework 0.1.48 → 0.1.49
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/cli/templates/make/app-spa/views/default/spa/src/components/FileUpload.vue +27 -11
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useApi.js +2 -2
- package/cli/templates/make/app-spa/views/default/spa/src/views/BoardForm.vue +3 -3
- package/cli/templates/make/app-ssr/routes/web.js +6 -4
- package/cli/templates/make/app-ssr/views/default/pages/board/form.html +35 -9
- package/index.js +1 -1
- package/lib/middleware/index.js +1 -0
- package/lib/middleware/loadUser.js +48 -0
- package/package.json +2 -2
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
</div>
|
|
55
55
|
<div class="progress-info" style="display: flex; justify-content: space-between; margin-top: 6px; font-size: 0.85rem; color: #aaa;">
|
|
56
56
|
<span>{{ progressText }}</span>
|
|
57
|
-
<span>{{
|
|
57
|
+
<span>{{ sizeText }}</span>
|
|
58
|
+
<span>{{ speedLabel }}</span>
|
|
58
59
|
</div>
|
|
59
60
|
</div>
|
|
60
61
|
</div>
|
|
@@ -62,6 +63,9 @@
|
|
|
62
63
|
|
|
63
64
|
<script setup>
|
|
64
65
|
import { ref, computed } from 'vue';
|
|
66
|
+
import { useLocale } from '../composables/useLocale.js';
|
|
67
|
+
|
|
68
|
+
const { t } = useLocale();
|
|
65
69
|
|
|
66
70
|
/**
|
|
67
71
|
* @prop {string} label - 레이블 텍스트
|
|
@@ -83,14 +87,16 @@ const selectedFiles = ref([]);
|
|
|
83
87
|
const uploading = ref(false);
|
|
84
88
|
const progress = ref(0);
|
|
85
89
|
const progressText = ref('0%');
|
|
86
|
-
const
|
|
90
|
+
const sizeText = ref('');
|
|
91
|
+
const speedLabel = ref('');
|
|
87
92
|
const progressColor = ref('linear-gradient(90deg, #667eea, #764ba2)');
|
|
88
93
|
|
|
89
94
|
/** 파일 크기 포맷 */
|
|
90
95
|
function formatSize(bytes) {
|
|
91
96
|
if (bytes < 1024) return bytes + ' B';
|
|
92
97
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
93
|
-
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
98
|
+
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
99
|
+
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
/** 파일 선택 이벤트 */
|
|
@@ -122,23 +128,32 @@ function setFiles(fileList) {
|
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
/** 진행률 업데이트 (외부에서 호출) */
|
|
125
|
-
function setProgress(pct, speed) {
|
|
131
|
+
function setProgress(pct, speed, loaded, total) {
|
|
126
132
|
uploading.value = true;
|
|
127
133
|
progress.value = pct;
|
|
128
134
|
progressText.value = pct + '%';
|
|
129
|
-
|
|
135
|
+
|
|
136
|
+
// 파일 크기 표시
|
|
137
|
+
if (loaded !== undefined && total !== undefined) {
|
|
138
|
+
sizeText.value = formatSize(loaded) + ' / ' + formatSize(total);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 속도 레이블
|
|
142
|
+
if (speed) {
|
|
143
|
+
speedLabel.value = t('board.upload_speed', '속도') + ': ' + speed;
|
|
144
|
+
}
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
/** 업로드 완료 */
|
|
133
|
-
function setComplete(text
|
|
148
|
+
function setComplete(text) {
|
|
134
149
|
progress.value = 100;
|
|
135
|
-
progressText.value = text;
|
|
136
|
-
|
|
150
|
+
progressText.value = text || t('board.upload_complete', '업로드 완료!');
|
|
151
|
+
speedLabel.value = '';
|
|
137
152
|
}
|
|
138
153
|
|
|
139
154
|
/** 업로드 오류 */
|
|
140
|
-
function setError(text
|
|
141
|
-
progressText.value = text;
|
|
155
|
+
function setError(text) {
|
|
156
|
+
progressText.value = text || t('board.upload_error', '오류 발생');
|
|
142
157
|
progressColor.value = '#e74c3c';
|
|
143
158
|
}
|
|
144
159
|
|
|
@@ -153,7 +168,8 @@ function reset() {
|
|
|
153
168
|
uploading.value = false;
|
|
154
169
|
progress.value = 0;
|
|
155
170
|
progressText.value = '0%';
|
|
156
|
-
|
|
171
|
+
sizeText.value = '';
|
|
172
|
+
speedLabel.value = '';
|
|
157
173
|
progressColor.value = 'linear-gradient(90deg, #667eea, #764ba2)';
|
|
158
174
|
}
|
|
159
175
|
|
|
@@ -80,7 +80,7 @@ export function useApi() {
|
|
|
80
80
|
* XHR 업로드 — 진행률 콜백 지원.
|
|
81
81
|
* @param {string} url - 업로드 URL
|
|
82
82
|
* @param {FormData} formData - 폼 데이터
|
|
83
|
-
* @param {Function} onProgress - 진행률 콜백 (percent, speed)
|
|
83
|
+
* @param {Function} onProgress - 진행률 콜백 (percent, speed, loaded, total)
|
|
84
84
|
* @param {string} [method='POST'] - HTTP 메서드
|
|
85
85
|
* @returns {Promise<{ok: boolean, responseURL: string}>}
|
|
86
86
|
*/
|
|
@@ -100,7 +100,7 @@ export function useApi() {
|
|
|
100
100
|
? (bps / 1048576).toFixed(1) + ' MB/s'
|
|
101
101
|
: (bps / 1024).toFixed(0) + ' KB/s';
|
|
102
102
|
}
|
|
103
|
-
onProgress(pct, speed);
|
|
103
|
+
onProgress(pct, speed, ev.loaded, ev.total);
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
xhr.onload = () => {
|
|
@@ -162,10 +162,10 @@ async function handleSubmit() {
|
|
|
162
162
|
try {
|
|
163
163
|
if (hasFiles) {
|
|
164
164
|
// XHR 업로드 + 진행률
|
|
165
|
-
const result = await api.uploadWithProgress(uploadUrl, formData, (pct, speed) => {
|
|
166
|
-
fileUploader.value?.setProgress(pct, speed);
|
|
165
|
+
const result = await api.uploadWithProgress(uploadUrl, formData, (pct, speed, loaded, total) => {
|
|
166
|
+
fileUploader.value?.setProgress(pct, speed, loaded, total);
|
|
167
167
|
}, method);
|
|
168
|
-
fileUploader.value?.setComplete(
|
|
168
|
+
fileUploader.value?.setComplete();
|
|
169
169
|
toast.success(isEdit.value ? t('board.updated', '글이 수정되었습니다.') : t('board.created', '글이 작성되었습니다.'));
|
|
170
170
|
setTimeout(() => {
|
|
171
171
|
router.push(isEdit.value ? `/board/${route.params.id}` : '/board');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { auth } from '@fuzionx/framework';
|
|
1
|
+
import { auth, loadUser } from '@fuzionx/framework';
|
|
2
2
|
import HomeController from '../controllers/HomeController.js';
|
|
3
3
|
import FeaturesController from '../controllers/FeaturesController.js';
|
|
4
4
|
import ChatController from '../controllers/ChatController.js';
|
|
@@ -7,9 +7,11 @@ import UserController from '../controllers/UserController.js';
|
|
|
7
7
|
import PostController from '../controllers/PostController.js';
|
|
8
8
|
|
|
9
9
|
export default (r) => {
|
|
10
|
-
// ── 공개 페이지 (인증
|
|
11
|
-
r.
|
|
12
|
-
|
|
10
|
+
// ── 공개 페이지 (인증 불필요, 세션 유저 로드) ──
|
|
11
|
+
r.group('', { middleware: [loadUser()] }, (r) => {
|
|
12
|
+
r.get('/', HomeController.index);
|
|
13
|
+
r.get('/features', FeaturesController.index);
|
|
14
|
+
});
|
|
13
15
|
|
|
14
16
|
// ── 인증 ──
|
|
15
17
|
r.get('/login', AuthController.loginPage);
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
</div>
|
|
82
82
|
<div class="progress-info" style="display:flex;justify-content:space-between;margin-top:6px;font-size:0.85rem;color:#aaa">
|
|
83
83
|
<span id="progressText">0%</span>
|
|
84
|
+
<span id="progressSize"></span>
|
|
84
85
|
<span id="progressSpeed"></span>
|
|
85
86
|
</div>
|
|
86
87
|
</div>
|
|
@@ -128,7 +129,8 @@ function editorInsert(text) {
|
|
|
128
129
|
function formatSize(bytes) {
|
|
129
130
|
if (bytes < 1024) return bytes + ' B';
|
|
130
131
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
131
|
-
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
132
|
+
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
133
|
+
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
function renderFiles() {
|
|
@@ -168,7 +170,25 @@ function editorInsert(text) {
|
|
|
168
170
|
});
|
|
169
171
|
})();
|
|
170
172
|
|
|
171
|
-
/**
|
|
173
|
+
/** i18n 텍스트 */
|
|
174
|
+
var _uploadLabels = {
|
|
175
|
+
speed: '{{ t(key="board.upload_speed", default="속도") }}',
|
|
176
|
+
complete: '{{ t(key="board.upload_complete", default="업로드 완료!") }}',
|
|
177
|
+
error: '{{ t(key="board.upload_error", default="오류 발생") }}',
|
|
178
|
+
networkError: '{{ t(key="board.upload_network_error", default="네트워크 오류") }}',
|
|
179
|
+
uploading: '{{ t(key="board.uploading", default="업로드 중...") }}',
|
|
180
|
+
thumbnailExtracting: '{{ t(key="board.thumbnail_extracting", default="🔄 썸네일 추출중...") }}'
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** 파일 크기 포맷 (진행률용) */
|
|
184
|
+
function _fmtSize(bytes) {
|
|
185
|
+
if (bytes < 1024) return bytes + ' B';
|
|
186
|
+
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
187
|
+
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
188
|
+
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** XHR 업로드 — 진행률 + 파일 크기 + 속도 + 자동 리다이렉트 */
|
|
172
192
|
function handleUpload(e) {
|
|
173
193
|
var fileInput = document.getElementById('fileInput');
|
|
174
194
|
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
|
|
@@ -181,10 +201,11 @@ function handleUpload(e) {
|
|
|
181
201
|
var progress = document.getElementById('uploadProgress');
|
|
182
202
|
var fill = document.getElementById('progressFill');
|
|
183
203
|
var pText = document.getElementById('progressText');
|
|
204
|
+
var pSize = document.getElementById('progressSize');
|
|
184
205
|
var pSpeed = document.getElementById('progressSpeed');
|
|
185
206
|
|
|
186
207
|
btn.disabled = true;
|
|
187
|
-
btn.textContent =
|
|
208
|
+
btn.textContent = _uploadLabels.uploading;
|
|
188
209
|
progress.style.display = 'block';
|
|
189
210
|
|
|
190
211
|
var formData = new FormData(form);
|
|
@@ -197,30 +218,35 @@ function handleUpload(e) {
|
|
|
197
218
|
fill.style.width = pct + '%';
|
|
198
219
|
pText.textContent = pct + '%';
|
|
199
220
|
|
|
221
|
+
// 파일 크기 표시 (loaded / total)
|
|
222
|
+
pSize.textContent = _fmtSize(ev.loaded) + ' / ' + _fmtSize(ev.total);
|
|
223
|
+
|
|
200
224
|
var elapsed = (Date.now() - startTime) / 1000;
|
|
201
225
|
if (elapsed > 0.5) {
|
|
202
226
|
var bps = ev.loaded / elapsed;
|
|
227
|
+
var speedVal;
|
|
203
228
|
if (bps > 1048576) {
|
|
204
|
-
|
|
229
|
+
speedVal = (bps / 1048576).toFixed(1) + ' MB/s';
|
|
205
230
|
} else {
|
|
206
|
-
|
|
231
|
+
speedVal = (bps / 1024).toFixed(0) + ' KB/s';
|
|
207
232
|
}
|
|
233
|
+
pSpeed.textContent = _uploadLabels.speed + ': ' + speedVal;
|
|
208
234
|
}
|
|
209
235
|
};
|
|
210
236
|
|
|
211
237
|
xhr.onload = function() {
|
|
212
238
|
if (xhr.status >= 200 && xhr.status < 400) {
|
|
213
239
|
fill.style.width = '100%';
|
|
214
|
-
pText.textContent =
|
|
240
|
+
pText.textContent = _uploadLabels.complete;
|
|
215
241
|
pSpeed.textContent = '';
|
|
216
|
-
btn.textContent =
|
|
242
|
+
btn.textContent = _uploadLabels.thumbnailExtracting;
|
|
217
243
|
setTimeout(function() {
|
|
218
244
|
window.location.href = xhr.responseURL || '/board';
|
|
219
245
|
}, 1500);
|
|
220
246
|
} else {
|
|
221
247
|
btn.disabled = false;
|
|
222
248
|
btn.textContent = '재시도';
|
|
223
|
-
pText.textContent =
|
|
249
|
+
pText.textContent = _uploadLabels.error;
|
|
224
250
|
fill.style.background = '#e74c3c';
|
|
225
251
|
}
|
|
226
252
|
};
|
|
@@ -228,7 +254,7 @@ function handleUpload(e) {
|
|
|
228
254
|
xhr.onerror = function() {
|
|
229
255
|
btn.disabled = false;
|
|
230
256
|
btn.textContent = '재시도';
|
|
231
|
-
pText.textContent =
|
|
257
|
+
pText.textContent = _uploadLabels.networkError;
|
|
232
258
|
fill.style.background = '#e74c3c';
|
|
233
259
|
};
|
|
234
260
|
|
package/index.js
CHANGED
|
@@ -64,4 +64,4 @@ export { default as OpenAPI } from './lib/view/OpenAPI.js';
|
|
|
64
64
|
export { PaginationUtil, StrUtil, NumUtil, DateUtil, ArrUtil, FunctionUtil, ObjectUtil } from './lib/utilities/index.js';
|
|
65
65
|
|
|
66
66
|
// ── Built-in Middleware ──
|
|
67
|
-
export { bodyParser, cors, auth, apiAuth, csrf, session, theme } from './lib/middleware/index.js';
|
|
67
|
+
export { bodyParser, cors, auth, apiAuth, csrf, session, theme, loadUser } from './lib/middleware/index.js';
|
package/lib/middleware/index.js
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loadUser — 세션 유저 로드 미들웨어 (비강제)
|
|
3
|
+
*
|
|
4
|
+
* auth 미들웨어와 동일하게 ctx.session.userId → db.User.find() → ctx.user 설정.
|
|
5
|
+
* 차이점: 세션이 없거나 userId가 없으면 redirect/401 없이 그냥 통과.
|
|
6
|
+
*
|
|
7
|
+
* 공개 페이지에서도 로그인 상태를 확인하여 네비게이션 메뉴 등에 반영할 때 사용.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // 글로벌 미들웨어로 등록 (모든 라우트에 적용)
|
|
11
|
+
* import { loadUser } from '@fuzionx/framework';
|
|
12
|
+
* app.use(loadUser());
|
|
13
|
+
*
|
|
14
|
+
* // 또는 특정 라우트/그룹에만 적용
|
|
15
|
+
* r.group('', { middleware: [loadUser()] }, (r) => {
|
|
16
|
+
* r.get('/', HomeController.index);
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* @see docs/framework/14-authentication.md
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [opts]
|
|
22
|
+
* @param {string} [opts.sessionKey='userId'] - 세션에서 유저 ID를 읽을 키
|
|
23
|
+
* @param {string} [opts.model='User'] - DB 모델명
|
|
24
|
+
*/
|
|
25
|
+
export function loadUser(opts = {}) {
|
|
26
|
+
const sessionKey = opts.sessionKey || 'userId';
|
|
27
|
+
const modelName = opts.model || 'User';
|
|
28
|
+
|
|
29
|
+
return async (ctx, next) => {
|
|
30
|
+
// 이미 auth 미들웨어로 ctx.user가 설정된 경우 스킵
|
|
31
|
+
if (ctx.user) {
|
|
32
|
+
await next();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const userId = ctx._rawReq?.session?.[sessionKey];
|
|
37
|
+
|
|
38
|
+
if (userId && ctx.app?.db?.[modelName]) {
|
|
39
|
+
try {
|
|
40
|
+
ctx.user = await ctx.app.db[modelName].find(userId);
|
|
41
|
+
} catch {
|
|
42
|
+
ctx.user = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await next();
|
|
47
|
+
};
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzionx/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.49",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
|
|
6
6
|
"main": "index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"url": "https://github.com/saytohenry/fuzionx"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@fuzionx/core": "^0.1.
|
|
37
|
+
"@fuzionx/core": "^0.1.49",
|
|
38
38
|
"better-sqlite3": "^12.8.0",
|
|
39
39
|
"knex": "^3.2.5",
|
|
40
40
|
"mongoose": "^9.3.2",
|