@fuzionx/framework 0.1.48 → 0.1.50
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/package.json +1 -0
- 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/components/Navbar.vue +1 -0
- 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/router/index.js +5 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/BoardForm.vue +3 -3
- package/cli/templates/make/app-spa/views/default/spa/src/views/LiveList.vue +488 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/LiveRoom.vue +573 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/LiveWatch.vue +319 -0
- package/cli/templates/make/app-ssr/controllers/LiveController.js +36 -0
- package/cli/templates/make/app-ssr/routes/web.js +12 -4
- package/cli/templates/make/app-ssr/views/default/layouts/main.html +2 -0
- package/cli/templates/make/app-ssr/views/default/pages/board/form.html +35 -9
- package/cli/templates/make/app-ssr/views/default/pages/live/index.html +351 -0
- package/cli/templates/make/app-ssr/views/default/pages/live/room.html +321 -0
- package/cli/templates/make/app-ssr/views/default/pages/live/watch.html +148 -0
- package/index.js +1 -1
- package/lib/middleware/index.js +1 -0
- package/lib/middleware/loadUser.js +48 -0
- package/package.json +2 -2
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
LiveWatch.vue — 방송 시청 페이지.
|
|
3
|
+
|
|
4
|
+
FuzionXViewer (mode=broadcast) 사용.
|
|
5
|
+
Hub API에서 채널 정보 조회 후 자동 연결.
|
|
6
|
+
비디오 플레이어 + 채팅 사이드바.
|
|
7
|
+
-->
|
|
8
|
+
<template>
|
|
9
|
+
<div class="live-watch-layout">
|
|
10
|
+
<!-- ── Video Area ── -->
|
|
11
|
+
<div class="live-watch-video">
|
|
12
|
+
<div class="live-watch-topbar">
|
|
13
|
+
<button class="btn btn-outline" style="padding:0.3rem 0.75rem;font-size:0.8rem;" @click="goBack">
|
|
14
|
+
← {{ t('live.back', '목록으로') }}
|
|
15
|
+
</button>
|
|
16
|
+
<div class="live-watch-info">
|
|
17
|
+
<span class="live-badge badge-broadcast">🔴 Broadcast</span>
|
|
18
|
+
<span class="live-watch-channel">{{ channelId }}</span>
|
|
19
|
+
<span class="live-viewers">👁 {{ viewerCount }}</span>
|
|
20
|
+
</div>
|
|
21
|
+
<span :class="['live-status', `live-status-${status}`]">{{ statusText }}</span>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="live-watch-player">
|
|
25
|
+
<video ref="videoEl" autoplay playsinline></video>
|
|
26
|
+
<div v-if="!connected" class="live-watch-placeholder">
|
|
27
|
+
<div v-if="error" style="color:#f87171;">❌ {{ error }}</div>
|
|
28
|
+
<div v-else>{{ t('live.connecting', '연결 중...') }}</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- ── Chat Sidebar ── -->
|
|
34
|
+
<aside class="live-chat-sidebar">
|
|
35
|
+
<div class="live-chat-header">
|
|
36
|
+
<h3>💬 {{ t('live.chat', 'Chat') }}</h3>
|
|
37
|
+
</div>
|
|
38
|
+
<div ref="chatContainer" class="live-chat-messages">
|
|
39
|
+
<template v-for="(msg, i) in chatMessages" :key="i">
|
|
40
|
+
<div v-if="msg.system" class="chat-msg-system">{{ msg.text }}</div>
|
|
41
|
+
<div v-else class="live-chat-msg">
|
|
42
|
+
<span class="live-chat-nick">{{ msg.nickname }}</span>
|
|
43
|
+
<span class="live-chat-text">{{ msg.text }}</span>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="live-chat-input-area">
|
|
48
|
+
<input
|
|
49
|
+
v-model="chatText"
|
|
50
|
+
type="text"
|
|
51
|
+
class="form-input"
|
|
52
|
+
:placeholder="t('live.chat_placeholder', '메시지 입력...')"
|
|
53
|
+
style="flex:1;"
|
|
54
|
+
@keydown.enter="sendChat"
|
|
55
|
+
/>
|
|
56
|
+
<button class="btn btn-primary" style="padding:0.4rem 0.75rem;" @click="sendChat">
|
|
57
|
+
{{ t('live.send', '전송') }}
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</aside>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<script setup>
|
|
65
|
+
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
|
66
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
67
|
+
import { useLocale } from '../composables/useLocale.js';
|
|
68
|
+
import { FuzionXViewer } from '@fuzionx/player';
|
|
69
|
+
|
|
70
|
+
const { t } = useLocale();
|
|
71
|
+
const route = useRoute();
|
|
72
|
+
const router = useRouter();
|
|
73
|
+
|
|
74
|
+
const channelId = route.params.channelId;
|
|
75
|
+
const hubUrl = route.query.hub || 'http://127.0.0.1:9100';
|
|
76
|
+
|
|
77
|
+
/* ── State ── */
|
|
78
|
+
const videoEl = ref(null);
|
|
79
|
+
const chatContainer = ref(null);
|
|
80
|
+
const connected = ref(false);
|
|
81
|
+
const status = ref('connecting');
|
|
82
|
+
const statusText = ref('connecting...');
|
|
83
|
+
const error = ref('');
|
|
84
|
+
const viewerCount = ref(0);
|
|
85
|
+
const chatMessages = ref([]);
|
|
86
|
+
const chatText = ref('');
|
|
87
|
+
|
|
88
|
+
let viewer = null;
|
|
89
|
+
|
|
90
|
+
/* ── Lifecycle ── */
|
|
91
|
+
onMounted(() => {
|
|
92
|
+
connectViewer();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
onUnmounted(() => {
|
|
96
|
+
if (viewer) {
|
|
97
|
+
viewer.disconnect();
|
|
98
|
+
viewer = null;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/* ── Connect ── */
|
|
103
|
+
function connectViewer() {
|
|
104
|
+
viewer = new FuzionXViewer({
|
|
105
|
+
hubUrl,
|
|
106
|
+
channelId,
|
|
107
|
+
mode: 'broadcast',
|
|
108
|
+
nickname: 'viewer-' + Math.random().toString(36).slice(2, 6),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
viewer.on('stream', (stream) => {
|
|
112
|
+
if (videoEl.value) {
|
|
113
|
+
videoEl.value.srcObject = stream;
|
|
114
|
+
videoEl.value.play().catch(() => {});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
viewer.on('connected', () => {
|
|
119
|
+
connected.value = true;
|
|
120
|
+
status.value = 'connected';
|
|
121
|
+
statusText.value = 'Connected';
|
|
122
|
+
appendSystem('WebRTC 연결됨');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
viewer.on('chat', ({ nickname, text }) => {
|
|
126
|
+
appendChat(nickname, text);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
viewer.on('error', (err) => {
|
|
130
|
+
error.value = err.message || String(err);
|
|
131
|
+
status.value = 'error';
|
|
132
|
+
statusText.value = 'Error';
|
|
133
|
+
appendSystem(`❌ ${err.message || err}`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
viewer.on('close', () => {
|
|
137
|
+
connected.value = false;
|
|
138
|
+
status.value = 'disconnected';
|
|
139
|
+
statusText.value = 'Disconnected';
|
|
140
|
+
appendSystem('연결 끊김');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
viewer.connect();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ── Chat ── */
|
|
147
|
+
function sendChat() {
|
|
148
|
+
const text = chatText.value.trim();
|
|
149
|
+
if (!text || !viewer) return;
|
|
150
|
+
viewer.chat(text);
|
|
151
|
+
appendChat('나', text);
|
|
152
|
+
chatText.value = '';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function appendChat(nickname, text) {
|
|
156
|
+
chatMessages.value.push({ nickname, text, system: false });
|
|
157
|
+
scrollChat();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function appendSystem(text) {
|
|
161
|
+
chatMessages.value.push({ text, system: true });
|
|
162
|
+
scrollChat();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function scrollChat() {
|
|
166
|
+
nextTick(() => {
|
|
167
|
+
if (chatContainer.value) {
|
|
168
|
+
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function goBack() {
|
|
174
|
+
router.push({ name: 'live' });
|
|
175
|
+
}
|
|
176
|
+
</script>
|
|
177
|
+
|
|
178
|
+
<style scoped>
|
|
179
|
+
.live-watch-layout {
|
|
180
|
+
display: flex;
|
|
181
|
+
height: calc(100vh - 60px);
|
|
182
|
+
overflow: hidden;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.live-watch-video {
|
|
186
|
+
flex: 1;
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
background: #000;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.live-watch-topbar {
|
|
193
|
+
display: flex;
|
|
194
|
+
align-items: center;
|
|
195
|
+
gap: 1rem;
|
|
196
|
+
padding: 0.5rem 1rem;
|
|
197
|
+
background: rgba(10, 10, 15, 0.9);
|
|
198
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.live-watch-info {
|
|
202
|
+
display: flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
gap: 0.75rem;
|
|
205
|
+
flex: 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.live-watch-channel {
|
|
209
|
+
font-weight: 600;
|
|
210
|
+
font-size: 0.95rem;
|
|
211
|
+
color: #e0e0e0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.live-viewers {
|
|
215
|
+
font-size: 0.8rem;
|
|
216
|
+
color: var(--text-muted, #888);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.live-status {
|
|
220
|
+
font-size: 0.75rem;
|
|
221
|
+
padding: 0.2rem 0.6rem;
|
|
222
|
+
border-radius: 9999px;
|
|
223
|
+
}
|
|
224
|
+
.live-status-connecting { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
|
|
225
|
+
.live-status-connected { background: rgba(16, 185, 129, 0.2); color: #10b981; }
|
|
226
|
+
.live-status-error { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
|
|
227
|
+
.live-status-disconnected { background: rgba(107, 114, 128, 0.2); color: #6b7280; }
|
|
228
|
+
|
|
229
|
+
.live-watch-player {
|
|
230
|
+
flex: 1;
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
justify-content: center;
|
|
234
|
+
position: relative;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.live-watch-player video {
|
|
238
|
+
max-width: 100%;
|
|
239
|
+
max-height: 100%;
|
|
240
|
+
object-fit: contain;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.live-watch-placeholder {
|
|
244
|
+
position: absolute;
|
|
245
|
+
color: var(--text-muted, #888);
|
|
246
|
+
font-size: 1rem;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* Chat Sidebar */
|
|
250
|
+
.live-chat-sidebar {
|
|
251
|
+
width: 320px;
|
|
252
|
+
background: rgba(18, 18, 31, 0.95);
|
|
253
|
+
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
|
254
|
+
display: flex;
|
|
255
|
+
flex-direction: column;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.live-chat-header {
|
|
259
|
+
padding: 0.75rem 1rem;
|
|
260
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
261
|
+
}
|
|
262
|
+
.live-chat-header h3 {
|
|
263
|
+
font-size: 0.9rem;
|
|
264
|
+
color: #e0e0e0;
|
|
265
|
+
margin: 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.live-chat-messages {
|
|
269
|
+
flex: 1;
|
|
270
|
+
overflow-y: auto;
|
|
271
|
+
padding: 0.75rem;
|
|
272
|
+
font-size: 0.8rem;
|
|
273
|
+
line-height: 1.5;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.live-chat-msg {
|
|
277
|
+
margin-bottom: 0.4rem;
|
|
278
|
+
}
|
|
279
|
+
.live-chat-nick {
|
|
280
|
+
color: #818cf8;
|
|
281
|
+
font-weight: 600;
|
|
282
|
+
margin-right: 0.4rem;
|
|
283
|
+
}
|
|
284
|
+
.live-chat-text {
|
|
285
|
+
color: #d0d0d0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.live-chat-input-area {
|
|
289
|
+
display: flex;
|
|
290
|
+
gap: 0.5rem;
|
|
291
|
+
padding: 0.5rem;
|
|
292
|
+
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* badges */
|
|
296
|
+
.live-badge {
|
|
297
|
+
display: inline-flex;
|
|
298
|
+
align-items: center;
|
|
299
|
+
gap: 0.25rem;
|
|
300
|
+
padding: 0.2rem 0.6rem;
|
|
301
|
+
border-radius: 9999px;
|
|
302
|
+
font-size: 0.7rem;
|
|
303
|
+
font-weight: 600;
|
|
304
|
+
}
|
|
305
|
+
.badge-broadcast {
|
|
306
|
+
background: rgba(239, 68, 68, 0.15);
|
|
307
|
+
color: #f87171;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@media (max-width: 768px) {
|
|
311
|
+
.live-watch-layout {
|
|
312
|
+
flex-direction: column;
|
|
313
|
+
}
|
|
314
|
+
.live-chat-sidebar {
|
|
315
|
+
width: 100%;
|
|
316
|
+
height: 40vh;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Controller } from '@fuzionx/framework';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SSR LiveController — WebRTC 라이브 채널 페이지
|
|
5
|
+
*
|
|
6
|
+
* 인증 불필요 — 공개 페이지.
|
|
7
|
+
*/
|
|
8
|
+
export default class LiveController extends Controller {
|
|
9
|
+
/** @type {import('@fuzionx/framework').RouteHandler} 채널 목록 */
|
|
10
|
+
static index;
|
|
11
|
+
/** @type {import('@fuzionx/framework').RouteHandler} 방송 시청 */
|
|
12
|
+
static watch;
|
|
13
|
+
/** @type {import('@fuzionx/framework').RouteHandler} 화상채팅 입장 */
|
|
14
|
+
static room;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GET /live — 라이브 채널 목록
|
|
18
|
+
*/
|
|
19
|
+
async index(ctx) {
|
|
20
|
+
ctx.render('live/index');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GET /live/watch/:channelId — 방송 시청
|
|
25
|
+
*/
|
|
26
|
+
async watch(ctx) {
|
|
27
|
+
ctx.render('live/watch', { channelId: ctx.params.channelId });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* GET /live/room/:channelId — 화상채팅 입장
|
|
32
|
+
*/
|
|
33
|
+
async room(ctx) {
|
|
34
|
+
ctx.render('live/room', { channelId: ctx.params.channelId });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
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';
|
|
5
|
+
import LiveController from '../controllers/LiveController.js';
|
|
5
6
|
import AuthController from '../controllers/AuthController.js';
|
|
6
7
|
import UserController from '../controllers/UserController.js';
|
|
7
8
|
import PostController from '../controllers/PostController.js';
|
|
8
9
|
|
|
9
10
|
export default (r) => {
|
|
10
|
-
// ── 공개 페이지 (인증
|
|
11
|
-
r.
|
|
12
|
-
|
|
11
|
+
// ── 공개 페이지 (인증 불필요, 세션 유저 로드) ──
|
|
12
|
+
r.group('', { middleware: [loadUser()] }, (r) => {
|
|
13
|
+
r.get('/', HomeController.index);
|
|
14
|
+
r.get('/features', FeaturesController.index);
|
|
15
|
+
});
|
|
13
16
|
|
|
14
17
|
// ── 인증 ──
|
|
15
18
|
r.get('/login', AuthController.loginPage);
|
|
@@ -23,6 +26,11 @@ export default (r) => {
|
|
|
23
26
|
|
|
24
27
|
// ── 인증 필요 (built-in auth 미들웨어) ──
|
|
25
28
|
r.group('', { middleware: [auth({ redirectTo: '/login' })] }, (r) => {
|
|
29
|
+
// 라이브
|
|
30
|
+
r.get('/live', LiveController.index);
|
|
31
|
+
r.get('/live/watch/:channelId', LiveController.watch);
|
|
32
|
+
r.get('/live/room/:channelId', LiveController.room);
|
|
33
|
+
|
|
26
34
|
// 채팅
|
|
27
35
|
r.get('/chat', ChatController.index);
|
|
28
36
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
{% block head %}{% endblock %}
|
|
14
14
|
<script>
|
|
15
15
|
window.__FX__ENC_ENABLED = {{ config.bridge.asp.enabled | default(value=false) }} || {{ config.app.asp.enabled | default(value=false) }}
|
|
16
|
+
{% if auth.user %}window.__FX_USER_NAME__ = "{{ auth.user.name }}";{% endif %}
|
|
16
17
|
</script>
|
|
17
18
|
</head>
|
|
18
19
|
<body>
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
<a href="/features" class="nav-link">{{ t(key="nav.features", default="Features") }}</a>
|
|
29
30
|
<a href="/chat" class="nav-link">{{ t(key="nav.chat", default="Chat Demo") }}</a>
|
|
30
31
|
<a href="/board" class="nav-link">{{ t(key="nav.board", default="게시판") }}</a>
|
|
32
|
+
<a href="/live" class="nav-link">{{ t(key="nav.live", default="Live") }}</a>
|
|
31
33
|
{% if auth.user %}
|
|
32
34
|
<a href="/profile" class="nav-link">{{ t(key="nav.profile", default="프로필") }}</a>
|
|
33
35
|
<form action="/logout" method="POST" class="nav-logout">
|
|
@@ -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
|
|