@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.
Files changed (92) hide show
  1. package/README.md +29 -2
  2. package/cli/index.js +37 -8
  3. package/cli/templates/make/app-spa/controllers/AuthController.js +114 -0
  4. package/cli/templates/make/app-spa/controllers/HomeController.js +66 -0
  5. package/cli/templates/make/app-spa/controllers/PostController.js +191 -0
  6. package/cli/templates/make/app-spa/controllers/UserController.js +43 -0
  7. package/cli/templates/make/app-spa/public/css/style.css +1011 -0
  8. package/cli/templates/make/app-spa/routes/api.js +31 -0
  9. package/cli/templates/make/app-spa/routes/web.js +19 -0
  10. package/cli/templates/make/app-spa/services/AuthService.js +48 -0
  11. package/cli/templates/make/app-spa/services/PostService.js +372 -0
  12. package/cli/templates/make/app-spa/services/UserService.js +48 -0
  13. package/cli/templates/make/app-spa/views/default/errors/404.html +11 -0
  14. package/cli/templates/make/app-spa/views/default/errors/500.html +11 -0
  15. package/cli/templates/make/app-spa/views/default/layouts/main.html +34 -0
  16. package/cli/templates/make/app-spa/views/default/pages/home.html +22 -0
  17. package/cli/templates/make/app-spa/views/default/spa/index.html +13 -0
  18. package/cli/templates/make/app-spa/views/default/spa/package.json +20 -0
  19. package/cli/templates/make/app-spa/views/default/spa/src/App.vue +41 -0
  20. package/cli/templates/make/app-spa/views/default/spa/src/assets/landing.css +220 -0
  21. package/cli/templates/make/app-spa/views/default/spa/src/assets/style.css +1156 -0
  22. package/cli/templates/make/app-spa/views/default/spa/src/components/AlertDialog.vue +179 -0
  23. package/cli/templates/make/app-spa/views/default/spa/src/components/CodeBlock.vue +33 -0
  24. package/cli/templates/make/app-spa/views/default/spa/src/components/EditorToolbar.vue +54 -0
  25. package/cli/templates/make/app-spa/views/default/spa/src/components/FileUpload.vue +161 -0
  26. package/cli/templates/make/app-spa/views/default/spa/src/components/FlashMessage.vue +39 -0
  27. package/cli/templates/make/app-spa/views/default/spa/src/components/LanguageSwitcher.vue +108 -0
  28. package/cli/templates/make/app-spa/views/default/spa/src/components/Lightbox.vue +62 -0
  29. package/cli/templates/make/app-spa/views/default/spa/src/components/Navbar.vue +68 -0
  30. package/cli/templates/make/app-spa/views/default/spa/src/components/Pagination.vue +166 -0
  31. package/cli/templates/make/app-spa/views/default/spa/src/components/ToastContainer.vue +135 -0
  32. package/cli/templates/make/app-spa/views/default/spa/src/composables/useApi.js +129 -0
  33. package/cli/templates/make/app-spa/views/default/spa/src/composables/useClipboard.js +44 -0
  34. package/cli/templates/make/app-spa/views/default/spa/src/composables/useDate.js +73 -0
  35. package/cli/templates/make/app-spa/views/default/spa/src/composables/useDebounce.js +59 -0
  36. package/cli/templates/make/app-spa/views/default/spa/src/composables/useFlash.js +46 -0
  37. package/cli/templates/make/app-spa/views/default/spa/src/composables/useHeartbeat.js +45 -0
  38. package/cli/templates/make/app-spa/views/default/spa/src/composables/useLocalStorage.js +43 -0
  39. package/cli/templates/make/app-spa/views/default/spa/src/composables/useLocale.js +79 -0
  40. package/cli/templates/make/app-spa/views/default/spa/src/composables/useWebSocket.js +93 -0
  41. package/cli/templates/make/app-spa/views/default/spa/src/main.js +106 -0
  42. package/cli/templates/make/app-spa/views/default/spa/src/plugins/alert.js +96 -0
  43. package/cli/templates/make/app-spa/views/default/spa/src/plugins/toast.js +79 -0
  44. package/cli/templates/make/app-spa/views/default/spa/src/router/index.js +29 -0
  45. package/cli/templates/make/app-spa/views/default/spa/src/stores/auth.js +58 -0
  46. package/cli/templates/make/app-spa/views/default/spa/src/views/BoardDetail.vue +169 -0
  47. package/cli/templates/make/app-spa/views/default/spa/src/views/BoardForm.vue +192 -0
  48. package/cli/templates/make/app-spa/views/default/spa/src/views/BoardList.vue +129 -0
  49. package/cli/templates/make/app-spa/views/default/spa/src/views/ChatView.vue +317 -0
  50. package/cli/templates/make/app-spa/views/default/spa/src/views/FeaturesView.vue +242 -0
  51. package/cli/templates/make/app-spa/views/default/spa/src/views/HomeView.vue +215 -0
  52. package/cli/templates/make/app-spa/views/default/spa/src/views/Login.vue +82 -0
  53. package/cli/templates/make/app-spa/views/default/spa/src/views/Profile.vue +85 -0
  54. package/cli/templates/make/app-spa/views/default/spa/src/views/Register.vue +84 -0
  55. package/cli/templates/make/app-spa/views/default/spa/vite.config.js +28 -0
  56. package/cli/templates/make/app-spa/views/default/spa/yarn.lock +633 -0
  57. package/cli/templates/make/app-spa/ws/ChatHandler.js +138 -0
  58. package/cli/templates/make/app-ssr/controllers/AuthController.js +119 -0
  59. package/cli/templates/make/app-ssr/controllers/ChatController.js +15 -0
  60. package/cli/templates/make/app-ssr/controllers/FeaturesController.js +15 -0
  61. package/cli/templates/make/app-ssr/controllers/HomeController.js +21 -0
  62. package/cli/templates/make/app-ssr/controllers/PostController.js +214 -0
  63. package/cli/templates/make/app-ssr/controllers/UserController.js +48 -0
  64. package/cli/templates/make/app-ssr/public/css/fx-ui.css +43 -0
  65. package/cli/templates/make/app-ssr/public/css/landing.css +220 -0
  66. package/cli/templates/make/app-ssr/public/css/style.css +1011 -0
  67. package/cli/templates/make/app-ssr/public/js/fx-client.js +107 -0
  68. package/cli/templates/make/app-ssr/public/js/fx-ui.js +124 -0
  69. package/cli/templates/make/app-ssr/routes/web.js +46 -0
  70. package/cli/templates/make/app-ssr/services/AuthService.js +48 -0
  71. package/cli/templates/make/app-ssr/services/PostService.js +372 -0
  72. package/cli/templates/make/app-ssr/services/UserService.js +48 -0
  73. package/cli/templates/make/app-ssr/views/default/errors/404.html +11 -0
  74. package/cli/templates/make/app-ssr/views/default/errors/500.html +48 -0
  75. package/cli/templates/make/app-ssr/views/default/layouts/main.html +96 -0
  76. package/cli/templates/make/app-ssr/views/default/pages/board/form.html +240 -0
  77. package/cli/templates/make/app-ssr/views/default/pages/board/index.html +73 -0
  78. package/cli/templates/make/app-ssr/views/default/pages/board/show.html +148 -0
  79. package/cli/templates/make/app-ssr/views/default/pages/chat.html +288 -0
  80. package/cli/templates/make/app-ssr/views/default/pages/features.html +373 -0
  81. package/cli/templates/make/app-ssr/views/default/pages/home.html +258 -0
  82. package/cli/templates/make/app-ssr/views/default/pages/login.html +27 -0
  83. package/cli/templates/make/app-ssr/views/default/pages/profile.html +36 -0
  84. package/cli/templates/make/app-ssr/views/default/pages/register.html +35 -0
  85. package/cli/templates/make/app-ssr/views/default/partials/pagination.html +75 -0
  86. package/cli/templates/make/app-ssr/ws/ChatHandler.js +138 -0
  87. package/lib/core/Application.js +425 -138
  88. package/lib/core/Context.js +540 -236
  89. package/lib/middleware/auth.js +1 -1
  90. package/lib/middleware/csrf.js +1 -1
  91. package/lib/middleware/session.js +5 -4
  92. 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>