@fuzionx/framework 0.1.45 → 0.1.47
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/README.md +29 -2
- package/cli/index.js +37 -8
- package/cli/templates/make/app-spa/controllers/AuthController.js +114 -0
- package/cli/templates/make/app-spa/controllers/HomeController.js +66 -0
- package/cli/templates/make/app-spa/controllers/PostController.js +191 -0
- package/cli/templates/make/app-spa/controllers/UserController.js +43 -0
- package/cli/templates/make/app-spa/public/css/style.css +1011 -0
- package/cli/templates/make/app-spa/routes/api.js +31 -0
- package/cli/templates/make/app-spa/routes/web.js +19 -0
- package/cli/templates/make/app-spa/services/AuthService.js +48 -0
- package/cli/templates/make/app-spa/services/PostService.js +372 -0
- package/cli/templates/make/app-spa/services/UserService.js +48 -0
- package/cli/templates/make/app-spa/views/default/errors/404.html +11 -0
- package/cli/templates/make/app-spa/views/default/errors/500.html +11 -0
- package/cli/templates/make/app-spa/views/default/layouts/main.html +34 -0
- package/cli/templates/make/app-spa/views/default/pages/home.html +22 -0
- package/cli/templates/make/app-spa/views/default/spa/index.html +13 -0
- package/cli/templates/make/app-spa/views/default/spa/package.json +20 -0
- package/cli/templates/make/app-spa/views/default/spa/src/App.vue +41 -0
- package/cli/templates/make/app-spa/views/default/spa/src/assets/landing.css +220 -0
- package/cli/templates/make/app-spa/views/default/spa/src/assets/style.css +1156 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/AlertDialog.vue +179 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/CodeBlock.vue +33 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/EditorToolbar.vue +54 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/FileUpload.vue +161 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/FlashMessage.vue +39 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/LanguageSwitcher.vue +108 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/Lightbox.vue +62 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/Navbar.vue +68 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/Pagination.vue +166 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/ToastContainer.vue +135 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useApi.js +129 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useClipboard.js +44 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useDate.js +73 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useDebounce.js +59 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useFlash.js +46 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useHeartbeat.js +45 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useLocalStorage.js +43 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useLocale.js +79 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useWebSocket.js +93 -0
- package/cli/templates/make/app-spa/views/default/spa/src/main.js +106 -0
- package/cli/templates/make/app-spa/views/default/spa/src/plugins/alert.js +96 -0
- package/cli/templates/make/app-spa/views/default/spa/src/plugins/toast.js +79 -0
- package/cli/templates/make/app-spa/views/default/spa/src/router/index.js +29 -0
- package/cli/templates/make/app-spa/views/default/spa/src/stores/auth.js +58 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/BoardDetail.vue +169 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/BoardForm.vue +192 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/BoardList.vue +129 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/ChatView.vue +317 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/FeaturesView.vue +242 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/HomeView.vue +215 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/Login.vue +82 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/Profile.vue +85 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/Register.vue +84 -0
- package/cli/templates/make/app-spa/views/default/spa/vite.config.js +28 -0
- package/cli/templates/make/app-spa/views/default/spa/yarn.lock +633 -0
- package/cli/templates/make/app-spa/ws/ChatHandler.js +138 -0
- package/cli/templates/make/app-ssr/controllers/AuthController.js +119 -0
- package/cli/templates/make/app-ssr/controllers/ChatController.js +15 -0
- package/cli/templates/make/app-ssr/controllers/FeaturesController.js +15 -0
- package/cli/templates/make/app-ssr/controllers/HomeController.js +21 -0
- package/cli/templates/make/app-ssr/controllers/PostController.js +214 -0
- package/cli/templates/make/app-ssr/controllers/UserController.js +48 -0
- package/cli/templates/make/app-ssr/public/css/fx-ui.css +43 -0
- package/cli/templates/make/app-ssr/public/css/landing.css +220 -0
- package/cli/templates/make/app-ssr/public/css/style.css +1011 -0
- package/cli/templates/make/app-ssr/public/js/fx-client.js +107 -0
- package/cli/templates/make/app-ssr/public/js/fx-ui.js +124 -0
- package/cli/templates/make/app-ssr/routes/web.js +46 -0
- package/cli/templates/make/app-ssr/services/AuthService.js +48 -0
- package/cli/templates/make/app-ssr/services/PostService.js +372 -0
- package/cli/templates/make/app-ssr/services/UserService.js +48 -0
- package/cli/templates/make/app-ssr/views/default/errors/404.html +11 -0
- package/cli/templates/make/app-ssr/views/default/errors/500.html +48 -0
- package/cli/templates/make/app-ssr/views/default/layouts/main.html +96 -0
- package/cli/templates/make/app-ssr/views/default/pages/board/form.html +240 -0
- package/cli/templates/make/app-ssr/views/default/pages/board/index.html +73 -0
- package/cli/templates/make/app-ssr/views/default/pages/board/show.html +148 -0
- package/cli/templates/make/app-ssr/views/default/pages/chat.html +288 -0
- package/cli/templates/make/app-ssr/views/default/pages/features.html +373 -0
- package/cli/templates/make/app-ssr/views/default/pages/home.html +258 -0
- package/cli/templates/make/app-ssr/views/default/pages/login.html +27 -0
- package/cli/templates/make/app-ssr/views/default/pages/profile.html +36 -0
- package/cli/templates/make/app-ssr/views/default/pages/register.html +35 -0
- package/cli/templates/make/app-ssr/views/default/partials/pagination.html +75 -0
- package/cli/templates/make/app-ssr/ws/ChatHandler.js +138 -0
- package/lib/core/Application.js +425 -138
- package/lib/core/Context.js +540 -236
- package/lib/middleware/auth.js +1 -1
- package/lib/middleware/csrf.js +1 -1
- package/lib/middleware/session.js +5 -4
- package/package.json +2 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
AlertDialog.vue — 글로벌 알럿/확인 다이얼로그.
|
|
3
|
+
|
|
4
|
+
App.vue에 1회 포함하면 $alert.show() / $alert.confirm() 으로 사용.
|
|
5
|
+
Promise 기반: confirm()은 true/false 반환.
|
|
6
|
+
-->
|
|
7
|
+
<template>
|
|
8
|
+
<Teleport to="body">
|
|
9
|
+
<Transition name="alert">
|
|
10
|
+
<div v-if="state.visible" class="fx-alert-backdrop" @click.self="onCancel">
|
|
11
|
+
<div :class="['fx-alert-dialog', `fx-alert-${state.type}`]">
|
|
12
|
+
<div class="fx-alert-header" v-if="state.title">
|
|
13
|
+
<span class="fx-alert-icon">{{ icons[state.type] || icons.info }}</span>
|
|
14
|
+
<h3 class="fx-alert-title">{{ state.title }}</h3>
|
|
15
|
+
</div>
|
|
16
|
+
<p class="fx-alert-message" v-if="state.message">{{ state.message }}</p>
|
|
17
|
+
<div class="fx-alert-actions">
|
|
18
|
+
<button
|
|
19
|
+
v-if="state.showCancel"
|
|
20
|
+
class="fx-alert-btn fx-alert-cancel"
|
|
21
|
+
@click="onCancel"
|
|
22
|
+
>{{ state.cancelText }}</button>
|
|
23
|
+
<button
|
|
24
|
+
class="fx-alert-btn fx-alert-ok"
|
|
25
|
+
@click="onOk"
|
|
26
|
+
ref="okBtn"
|
|
27
|
+
>{{ state.okText }}</button>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</Transition>
|
|
32
|
+
</Teleport>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup>
|
|
36
|
+
import { ref, watch, nextTick } from 'vue';
|
|
37
|
+
import { alertState, handleOk, handleCancel } from '../plugins/alert.js';
|
|
38
|
+
|
|
39
|
+
const state = alertState;
|
|
40
|
+
const okBtn = ref(null);
|
|
41
|
+
|
|
42
|
+
const icons = {
|
|
43
|
+
success: '✓',
|
|
44
|
+
error: '✕',
|
|
45
|
+
warning: '⚠',
|
|
46
|
+
info: 'ℹ',
|
|
47
|
+
confirm: '?',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function onOk() { handleOk(); }
|
|
51
|
+
function onCancel() { handleCancel(); }
|
|
52
|
+
|
|
53
|
+
// 열릴 때 OK 버튼에 포커스
|
|
54
|
+
watch(() => state.visible, async (v) => {
|
|
55
|
+
if (v) {
|
|
56
|
+
await nextTick();
|
|
57
|
+
okBtn.value?.focus();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
/* ── Alert Backdrop ── */
|
|
64
|
+
.fx-alert-backdrop {
|
|
65
|
+
position: fixed;
|
|
66
|
+
inset: 0;
|
|
67
|
+
background: rgba(0,0,0,0.6);
|
|
68
|
+
z-index: 10001;
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
padding: 1rem;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ── Alert Dialog ── */
|
|
76
|
+
.fx-alert-dialog {
|
|
77
|
+
background: rgba(25, 25, 50, 0.98);
|
|
78
|
+
backdrop-filter: blur(20px);
|
|
79
|
+
border: 1px solid rgba(255,255,255,0.12);
|
|
80
|
+
border-radius: 16px;
|
|
81
|
+
padding: 2rem;
|
|
82
|
+
max-width: 420px;
|
|
83
|
+
width: 100%;
|
|
84
|
+
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
|
85
|
+
}
|
|
86
|
+
.fx-alert-header {
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
gap: 0.75rem;
|
|
90
|
+
margin-bottom: 0.75rem;
|
|
91
|
+
}
|
|
92
|
+
.fx-alert-icon {
|
|
93
|
+
width: 36px;
|
|
94
|
+
height: 36px;
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
border-radius: 10px;
|
|
99
|
+
font-size: 1.1rem;
|
|
100
|
+
flex-shrink: 0;
|
|
101
|
+
}
|
|
102
|
+
.fx-alert-info .fx-alert-icon {
|
|
103
|
+
background: rgba(102,126,234,0.15);
|
|
104
|
+
color: #7b93ff;
|
|
105
|
+
}
|
|
106
|
+
.fx-alert-confirm .fx-alert-icon {
|
|
107
|
+
background: rgba(102,126,234,0.15);
|
|
108
|
+
color: #7b93ff;
|
|
109
|
+
}
|
|
110
|
+
.fx-alert-warning .fx-alert-icon {
|
|
111
|
+
background: rgba(241,196,15,0.15);
|
|
112
|
+
color: #f1c40f;
|
|
113
|
+
}
|
|
114
|
+
.fx-alert-error .fx-alert-icon {
|
|
115
|
+
background: rgba(231,76,60,0.15);
|
|
116
|
+
color: #e74c3c;
|
|
117
|
+
}
|
|
118
|
+
.fx-alert-title {
|
|
119
|
+
font-size: 1.15rem;
|
|
120
|
+
font-weight: 700;
|
|
121
|
+
color: #fff;
|
|
122
|
+
margin: 0;
|
|
123
|
+
}
|
|
124
|
+
.fx-alert-message {
|
|
125
|
+
font-size: 0.9rem;
|
|
126
|
+
color: rgba(255,255,255,0.7);
|
|
127
|
+
line-height: 1.6;
|
|
128
|
+
margin: 0 0 1.5rem;
|
|
129
|
+
}
|
|
130
|
+
.fx-alert-actions {
|
|
131
|
+
display: flex;
|
|
132
|
+
gap: 0.75rem;
|
|
133
|
+
justify-content: flex-end;
|
|
134
|
+
}
|
|
135
|
+
.fx-alert-btn {
|
|
136
|
+
padding: 0.6rem 1.5rem;
|
|
137
|
+
border-radius: 10px;
|
|
138
|
+
font-size: 0.88rem;
|
|
139
|
+
font-weight: 600;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
border: none;
|
|
142
|
+
transition: all 0.2s;
|
|
143
|
+
font-family: inherit;
|
|
144
|
+
}
|
|
145
|
+
.fx-alert-ok {
|
|
146
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
147
|
+
color: #fff;
|
|
148
|
+
}
|
|
149
|
+
.fx-alert-ok:hover {
|
|
150
|
+
transform: translateY(-1px);
|
|
151
|
+
box-shadow: 0 4px 15px rgba(102,126,234,0.3);
|
|
152
|
+
}
|
|
153
|
+
.fx-alert-cancel {
|
|
154
|
+
background: rgba(255,255,255,0.08);
|
|
155
|
+
color: rgba(255,255,255,0.7);
|
|
156
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
157
|
+
}
|
|
158
|
+
.fx-alert-cancel:hover {
|
|
159
|
+
background: rgba(255,255,255,0.12);
|
|
160
|
+
color: #fff;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* Transition */
|
|
164
|
+
.alert-enter-active { animation: alertIn 0.25s ease-out; }
|
|
165
|
+
.alert-leave-active { animation: alertOut 0.2s ease-in forwards; }
|
|
166
|
+
|
|
167
|
+
@keyframes alertIn {
|
|
168
|
+
from { opacity: 0; }
|
|
169
|
+
to { opacity: 1; }
|
|
170
|
+
}
|
|
171
|
+
@keyframes alertIn {
|
|
172
|
+
from { opacity: 0; transform: scale(0.95); }
|
|
173
|
+
to { opacity: 1; transform: scale(1); }
|
|
174
|
+
}
|
|
175
|
+
@keyframes alertOut {
|
|
176
|
+
from { opacity: 1; transform: scale(1); }
|
|
177
|
+
to { opacity: 0; transform: scale(0.95); }
|
|
178
|
+
}
|
|
179
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
CodeBlock.vue — 코드 블록 재사용 컴포넌트.
|
|
3
|
+
|
|
4
|
+
SSR features.html / home.html의 코드 블록 스타일을 Vue 컴포넌트로 추출.
|
|
5
|
+
코드 헤더(점 3개 + 제목) + pre 코드 본문.
|
|
6
|
+
|
|
7
|
+
v-html 기반 렌더링으로 <span> 태그 줄바꿈 보장.
|
|
8
|
+
@example
|
|
9
|
+
<CodeBlock title="app.js" :code="codeHtml" />
|
|
10
|
+
-->
|
|
11
|
+
<template>
|
|
12
|
+
<div class="feat-code-block">
|
|
13
|
+
<div class="code-header">
|
|
14
|
+
<span class="code-dot red"></span>
|
|
15
|
+
<span class="code-dot yellow"></span>
|
|
16
|
+
<span class="code-dot green"></span>
|
|
17
|
+
<span class="code-title">{{ title }}</span>
|
|
18
|
+
</div>
|
|
19
|
+
<pre class="code-body" v-if="code" v-html="code"></pre>
|
|
20
|
+
<pre class="code-body" v-else><slot /></pre>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup>
|
|
25
|
+
/**
|
|
26
|
+
* @prop {string} title - 코드 블록 상단 타이틀 (예: 'app.js', 'Terminal')
|
|
27
|
+
* @prop {string} code - HTML 코드 문자열 (v-html로 렌더링, 줄바꿈 보장)
|
|
28
|
+
*/
|
|
29
|
+
defineProps({
|
|
30
|
+
title: { type: String, default: '' },
|
|
31
|
+
code: { type: String, default: '' },
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
EditorToolbar.vue — 마크다운 에디터 툴바.
|
|
3
|
+
|
|
4
|
+
SSR board/form.html의 에디터 툴바를 Vue 컴포넌트로 추출.
|
|
5
|
+
Bold, Italic, Underline, H1, H2, List, Quote, Code, Link.
|
|
6
|
+
-->
|
|
7
|
+
<template>
|
|
8
|
+
<div class="editor-toolbar">
|
|
9
|
+
<button type="button" @click="wrap('bold')" title="Bold"><b>B</b></button>
|
|
10
|
+
<button type="button" @click="wrap('italic')" title="Italic"><i>I</i></button>
|
|
11
|
+
<button type="button" @click="wrap('underline')" title="Underline"><u>U</u></button>
|
|
12
|
+
<div class="separator"></div>
|
|
13
|
+
<button type="button" @click="insert('# ')" title="H1">H1</button>
|
|
14
|
+
<button type="button" @click="insert('## ')" title="H2">H2</button>
|
|
15
|
+
<div class="separator"></div>
|
|
16
|
+
<button type="button" @click="insert('- ')" title="List">⊙</button>
|
|
17
|
+
<button type="button" @click="insert('> ')" title="Quote">❝</button>
|
|
18
|
+
<button type="button" @click="insert('```\n\n```')" title="Code">⟨/⟩</button>
|
|
19
|
+
<div class="separator"></div>
|
|
20
|
+
<button type="button" @click="insert('[링크](url)')" title="Link">🔗</button>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup>
|
|
25
|
+
/**
|
|
26
|
+
* @prop {string} targetId - 연결할 textarea의 ref ID
|
|
27
|
+
*/
|
|
28
|
+
const props = defineProps({
|
|
29
|
+
targetId: { type: String, required: true },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** 선택 텍스트 감싸기 (Bold/Italic/Underline) */
|
|
33
|
+
function wrap(type) {
|
|
34
|
+
const ta = document.getElementById(props.targetId);
|
|
35
|
+
if (!ta) return;
|
|
36
|
+
const start = ta.selectionStart;
|
|
37
|
+
const end = ta.selectionEnd;
|
|
38
|
+
const sel = ta.value.substring(start, end);
|
|
39
|
+
const wrapMap = { bold: ['**', '**'], italic: ['_', '_'], underline: ['__', '__'] };
|
|
40
|
+
const w = wrapMap[type] || ['', ''];
|
|
41
|
+
ta.setRangeText(w[0] + sel + w[1], start, end, 'end');
|
|
42
|
+
ta.focus();
|
|
43
|
+
ta.dispatchEvent(new Event('input'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 커서 위치에 텍스트 삽입 */
|
|
47
|
+
function insert(text) {
|
|
48
|
+
const ta = document.getElementById(props.targetId);
|
|
49
|
+
if (!ta) return;
|
|
50
|
+
ta.setRangeText(text, ta.selectionStart, ta.selectionStart, 'end');
|
|
51
|
+
ta.focus();
|
|
52
|
+
ta.dispatchEvent(new Event('input'));
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
FileUpload.vue — 드래그&드롭 파일 업로드 + 진행률 컴포넌트.
|
|
3
|
+
|
|
4
|
+
SSR board/form.html의 파일 업로드 영역을 Vue 컴포넌트로 추출.
|
|
5
|
+
드래그&드롭, 파일 미리보기, 진행률 바 포함.
|
|
6
|
+
-->
|
|
7
|
+
<template>
|
|
8
|
+
<div class="form-group">
|
|
9
|
+
<label>{{ label }}</label>
|
|
10
|
+
|
|
11
|
+
<!-- 드래그&드롭 영역 -->
|
|
12
|
+
<div
|
|
13
|
+
:class="['file-upload-area', { dragover: isDragover }]"
|
|
14
|
+
@click="$refs.fileInput.click()"
|
|
15
|
+
@dragenter.prevent="isDragover = true"
|
|
16
|
+
@dragover.prevent="isDragover = true"
|
|
17
|
+
@dragleave.prevent="isDragover = false"
|
|
18
|
+
@drop.prevent="onDrop"
|
|
19
|
+
>
|
|
20
|
+
<div class="upload-icon">📎</div>
|
|
21
|
+
<div class="upload-text">{{ dropText }}</div>
|
|
22
|
+
<div class="upload-hint">{{ hintText }}</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- 숨겨진 파일 input -->
|
|
26
|
+
<input
|
|
27
|
+
ref="fileInput"
|
|
28
|
+
type="file"
|
|
29
|
+
:name="name"
|
|
30
|
+
multiple
|
|
31
|
+
style="display: none;"
|
|
32
|
+
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
|
|
33
|
+
@change="onFileChange"
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
<!-- 선택된 파일 목록 -->
|
|
37
|
+
<div class="file-list">
|
|
38
|
+
<div v-for="(f, i) in selectedFiles" :key="i" class="file-item">
|
|
39
|
+
<img v-if="f.preview" :src="f.preview" class="file-preview" />
|
|
40
|
+
<span v-else-if="f.isVideo" class="file-preview-icon">🎬</span>
|
|
41
|
+
<span v-else class="file-preview-icon">📄</span>
|
|
42
|
+
<span class="file-name">{{ f.name }}</span>
|
|
43
|
+
<span class="file-size">{{ formatSize(f.size) }}</span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<!-- 진행률 바 -->
|
|
48
|
+
<div v-if="uploading" class="upload-progress" style="margin: 1rem 0;">
|
|
49
|
+
<div class="progress-bar-track" style="background: rgba(255,255,255,0.15); border-radius: 8px; height: 14px; overflow: hidden;">
|
|
50
|
+
<div
|
|
51
|
+
class="progress-bar-fill"
|
|
52
|
+
:style="{ width: progress + '%', height: '100%', background: progressColor, borderRadius: '8px', transition: 'width 0.3s ease' }"
|
|
53
|
+
></div>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="progress-info" style="display: flex; justify-content: space-between; margin-top: 6px; font-size: 0.85rem; color: #aaa;">
|
|
56
|
+
<span>{{ progressText }}</span>
|
|
57
|
+
<span>{{ speedText }}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
62
|
+
|
|
63
|
+
<script setup>
|
|
64
|
+
import { ref, computed } from 'vue';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @prop {string} label - 레이블 텍스트
|
|
68
|
+
* @prop {string} name - form field name
|
|
69
|
+
* @prop {string} dropText - 드래그 영역 메인 텍스트
|
|
70
|
+
* @prop {string} hintText - 드래그 영역 힌트 텍스트
|
|
71
|
+
*/
|
|
72
|
+
const props = defineProps({
|
|
73
|
+
label: { type: String, default: '첨부파일' },
|
|
74
|
+
name: { type: String, default: 'files' },
|
|
75
|
+
dropText: { type: String, default: '클릭 또는 파일을 여기에 드래그' },
|
|
76
|
+
hintText: { type: String, default: '이미지, 비디오, 문서 파일' },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const emit = defineEmits(['update:files']);
|
|
80
|
+
|
|
81
|
+
const isDragover = ref(false);
|
|
82
|
+
const selectedFiles = ref([]);
|
|
83
|
+
const uploading = ref(false);
|
|
84
|
+
const progress = ref(0);
|
|
85
|
+
const progressText = ref('0%');
|
|
86
|
+
const speedText = ref('');
|
|
87
|
+
const progressColor = ref('linear-gradient(90deg, #667eea, #764ba2)');
|
|
88
|
+
|
|
89
|
+
/** 파일 크기 포맷 */
|
|
90
|
+
function formatSize(bytes) {
|
|
91
|
+
if (bytes < 1024) return bytes + ' B';
|
|
92
|
+
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
93
|
+
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 파일 선택 이벤트 */
|
|
97
|
+
function onFileChange(e) {
|
|
98
|
+
setFiles(e.target.files);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 드래그&드롭 이벤트 */
|
|
102
|
+
function onDrop(e) {
|
|
103
|
+
isDragover.value = false;
|
|
104
|
+
setFiles(e.dataTransfer.files);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 선택된 파일 설정 + 미리보기 생성 */
|
|
108
|
+
function setFiles(fileList) {
|
|
109
|
+
const arr = [];
|
|
110
|
+
for (let i = 0; i < fileList.length; i++) {
|
|
111
|
+
const f = fileList[i];
|
|
112
|
+
arr.push({
|
|
113
|
+
name: f.name,
|
|
114
|
+
size: f.size,
|
|
115
|
+
isVideo: f.type.startsWith('video/'),
|
|
116
|
+
preview: f.type.startsWith('image/') ? URL.createObjectURL(f) : null,
|
|
117
|
+
raw: f,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
selectedFiles.value = arr;
|
|
121
|
+
emit('update:files', fileList);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 진행률 업데이트 (외부에서 호출) */
|
|
125
|
+
function setProgress(pct, speed) {
|
|
126
|
+
uploading.value = true;
|
|
127
|
+
progress.value = pct;
|
|
128
|
+
progressText.value = pct + '%';
|
|
129
|
+
speedText.value = speed || '';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 업로드 완료 */
|
|
133
|
+
function setComplete(text = '업로드 완료!') {
|
|
134
|
+
progress.value = 100;
|
|
135
|
+
progressText.value = text;
|
|
136
|
+
speedText.value = '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 업로드 오류 */
|
|
140
|
+
function setError(text = '오류 발생') {
|
|
141
|
+
progressText.value = text;
|
|
142
|
+
progressColor.value = '#e74c3c';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 파일 목록 반환 */
|
|
146
|
+
function getFiles() {
|
|
147
|
+
return selectedFiles.value.map(f => f.raw);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 상태 초기화 */
|
|
151
|
+
function reset() {
|
|
152
|
+
selectedFiles.value = [];
|
|
153
|
+
uploading.value = false;
|
|
154
|
+
progress.value = 0;
|
|
155
|
+
progressText.value = '0%';
|
|
156
|
+
speedText.value = '';
|
|
157
|
+
progressColor.value = 'linear-gradient(90deg, #667eea, #764ba2)';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
defineExpose({ setProgress, setComplete, setError, getFiles, reset });
|
|
161
|
+
</script>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
FlashMessage.vue — Alert 표시 컴포넌트.
|
|
3
|
+
|
|
4
|
+
SSR main.html의 flash.error / flash.success 표시를 Vue 컴포넌트로 구현.
|
|
5
|
+
자동 사라짐 + 수동 닫기 지원.
|
|
6
|
+
-->
|
|
7
|
+
<template>
|
|
8
|
+
<Transition name="flash-fade">
|
|
9
|
+
<div v-if="flash.message" class="container" style="margin-bottom: -1rem;">
|
|
10
|
+
<div :class="['alert', `alert-${flash.type}`]">
|
|
11
|
+
{{ flash.message }}
|
|
12
|
+
<button class="flash-close" @click="clear">✕</button>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</Transition>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup>
|
|
19
|
+
import { useFlash } from '../composables/useFlash.js';
|
|
20
|
+
|
|
21
|
+
const { flash, clear } = useFlash();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<style scoped>
|
|
25
|
+
.flash-close {
|
|
26
|
+
float: right;
|
|
27
|
+
background: none;
|
|
28
|
+
border: none;
|
|
29
|
+
color: inherit;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
font-size: 0.9rem;
|
|
32
|
+
opacity: 0.7;
|
|
33
|
+
}
|
|
34
|
+
.flash-close:hover { opacity: 1; }
|
|
35
|
+
.flash-fade-enter-active,
|
|
36
|
+
.flash-fade-leave-active { transition: opacity 0.3s, transform 0.3s; }
|
|
37
|
+
.flash-fade-enter-from,
|
|
38
|
+
.flash-fade-leave-to { opacity: 0; transform: translateY(-10px); }
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
LanguageSwitcher.vue — 다국어 드롭다운 메뉴.
|
|
3
|
+
|
|
4
|
+
서버에서 제공하는 locales 목록을 기반으로 동적 렌더링.
|
|
5
|
+
새 언어 파일 추가 시 자동으로 반영됨.
|
|
6
|
+
-->
|
|
7
|
+
<template>
|
|
8
|
+
<div :class="['lang-switcher', { open }]" ref="root">
|
|
9
|
+
<button class="lang-switcher-btn" @click="open = !open" :title="currentLabel">
|
|
10
|
+
{{ locale.toUpperCase() }}
|
|
11
|
+
<svg width="10" height="6" viewBox="0 0 10 6" fill="currentColor"><path d="M1 1l4 4 4-4"/></svg>
|
|
12
|
+
</button>
|
|
13
|
+
<ul class="lang-switcher-menu">
|
|
14
|
+
<li
|
|
15
|
+
v-for="loc in availableLocales"
|
|
16
|
+
:key="loc.code"
|
|
17
|
+
:class="['lang-switcher-item', { active: loc.code === locale }]"
|
|
18
|
+
>
|
|
19
|
+
<a :href="`?lang=${loc.code}`" class="lang-switcher-link">
|
|
20
|
+
{{ loc.label }}
|
|
21
|
+
</a>
|
|
22
|
+
</li>
|
|
23
|
+
</ul>
|
|
24
|
+
</div>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<script setup>
|
|
28
|
+
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
29
|
+
import { useLocale } from '../composables/useLocale.js';
|
|
30
|
+
|
|
31
|
+
const { locale, locales } = useLocale();
|
|
32
|
+
const open = ref(false);
|
|
33
|
+
|
|
34
|
+
const availableLocales = computed(() => locales());
|
|
35
|
+
const currentLabel = computed(() => {
|
|
36
|
+
const found = availableLocales.value.find(l => l.code === locale.value);
|
|
37
|
+
return found ? found.label : locale.value;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// 외부 클릭 시 닫기
|
|
41
|
+
const root = ref(null);
|
|
42
|
+
function onClickOutside(e) {
|
|
43
|
+
if (root.value && !root.value.contains(e.target)) {
|
|
44
|
+
open.value = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
onMounted(() => document.addEventListener('click', onClickOutside));
|
|
48
|
+
onUnmounted(() => document.removeEventListener('click', onClickOutside));
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<style scoped>
|
|
52
|
+
.lang-switcher {
|
|
53
|
+
position: relative;
|
|
54
|
+
display: inline-flex;
|
|
55
|
+
}
|
|
56
|
+
.lang-switcher-btn {
|
|
57
|
+
background: rgba(255,255,255,0.1);
|
|
58
|
+
border: 1px solid rgba(255,255,255,0.2);
|
|
59
|
+
color: inherit;
|
|
60
|
+
padding: 0.3rem 0.7rem;
|
|
61
|
+
border-radius: 6px;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
font-size: 0.8rem;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 0.3rem;
|
|
68
|
+
transition: background 0.2s;
|
|
69
|
+
}
|
|
70
|
+
.lang-switcher-btn:hover {
|
|
71
|
+
background: rgba(255,255,255,0.2);
|
|
72
|
+
}
|
|
73
|
+
.lang-switcher-menu {
|
|
74
|
+
display: none;
|
|
75
|
+
position: absolute;
|
|
76
|
+
top: calc(100% + 4px);
|
|
77
|
+
right: 0;
|
|
78
|
+
background: rgba(20, 20, 40, 0.95);
|
|
79
|
+
backdrop-filter: blur(12px);
|
|
80
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
81
|
+
border-radius: 8px;
|
|
82
|
+
list-style: none;
|
|
83
|
+
margin: 0;
|
|
84
|
+
padding: 4px;
|
|
85
|
+
min-width: 140px;
|
|
86
|
+
z-index: 1000;
|
|
87
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
88
|
+
}
|
|
89
|
+
.lang-switcher.open .lang-switcher-menu {
|
|
90
|
+
display: block;
|
|
91
|
+
}
|
|
92
|
+
.lang-switcher-item.active .lang-switcher-link {
|
|
93
|
+
color: var(--accent, #a78bfa);
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
.lang-switcher-link {
|
|
97
|
+
display: block;
|
|
98
|
+
padding: 0.45rem 0.8rem;
|
|
99
|
+
color: rgba(255,255,255,0.85);
|
|
100
|
+
text-decoration: none;
|
|
101
|
+
font-size: 0.85rem;
|
|
102
|
+
border-radius: 5px;
|
|
103
|
+
transition: background 0.15s;
|
|
104
|
+
}
|
|
105
|
+
.lang-switcher-link:hover {
|
|
106
|
+
background: rgba(255,255,255,0.1);
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Lightbox.vue — 이미지/비디오 모달 컴포넌트.
|
|
3
|
+
|
|
4
|
+
SSR board/show.html의 Lightbox 모달을 Vue 컴포넌트로 구현.
|
|
5
|
+
이미지 원본 보기 / 비디오 인라인 재생 모달.
|
|
6
|
+
-->
|
|
7
|
+
<template>
|
|
8
|
+
<Teleport to="body">
|
|
9
|
+
<div
|
|
10
|
+
v-if="visible"
|
|
11
|
+
class="lightbox-overlay"
|
|
12
|
+
style="display: flex;"
|
|
13
|
+
@click.self="close"
|
|
14
|
+
>
|
|
15
|
+
<div class="lightbox-content">
|
|
16
|
+
<button class="lightbox-close" @click="close">✕</button>
|
|
17
|
+
<!-- 비디오 -->
|
|
18
|
+
<video
|
|
19
|
+
v-if="mediaType === 'video'"
|
|
20
|
+
controls
|
|
21
|
+
autoplay
|
|
22
|
+
style="max-width: 100%; max-height: 80vh;"
|
|
23
|
+
>
|
|
24
|
+
<source :src="mediaSrc" />
|
|
25
|
+
</video>
|
|
26
|
+
<!-- 이미지 -->
|
|
27
|
+
<img
|
|
28
|
+
v-else
|
|
29
|
+
:src="mediaSrc"
|
|
30
|
+
style="max-width: 100%; max-height: 80vh;"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</Teleport>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup>
|
|
38
|
+
import { ref } from 'vue';
|
|
39
|
+
|
|
40
|
+
const visible = ref(false);
|
|
41
|
+
const mediaSrc = ref('');
|
|
42
|
+
const mediaType = ref('image');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lightbox 열기.
|
|
46
|
+
* @param {string} src - 미디어 URL
|
|
47
|
+
* @param {'image'|'video'} type - 미디어 타입
|
|
48
|
+
*/
|
|
49
|
+
function open(src, type = 'image') {
|
|
50
|
+
mediaSrc.value = src;
|
|
51
|
+
mediaType.value = type;
|
|
52
|
+
visible.value = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Lightbox 닫기 */
|
|
56
|
+
function close() {
|
|
57
|
+
visible.value = false;
|
|
58
|
+
mediaSrc.value = '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
defineExpose({ open, close });
|
|
62
|
+
</script>
|