@fuzionx/framework 0.1.46 → 0.1.48
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 +57 -18
- 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/package.json +2 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
BoardDetail.vue — 게시글 상세 페이지.
|
|
3
|
+
|
|
4
|
+
SSR board/show.html 완전 복제:
|
|
5
|
+
게시글 제목 (processing 뱃지), 메타 정보, 본문,
|
|
6
|
+
비디오 썸네일, 첨부파일 그리드 (이미지/비디오/일반 파일),
|
|
7
|
+
Lightbox 모달, Processing 상태 폴링.
|
|
8
|
+
-->
|
|
9
|
+
<template>
|
|
10
|
+
<div class="container">
|
|
11
|
+
<div v-if="post" class="glass-card">
|
|
12
|
+
<div class="page-header">
|
|
13
|
+
<h1 class="page-title">
|
|
14
|
+
<span v-if="post.status === 'processing'" class="badge badge-processing">🔄 {{ t('board.processing', '처리중') }}</span>
|
|
15
|
+
{{ post.title }}
|
|
16
|
+
</h1>
|
|
17
|
+
<div v-if="isOwner" class="page-actions">
|
|
18
|
+
<router-link :to="`/board/${post.id}/edit`" class="btn btn-outline">{{ t('btn.edit', '수정') }}</router-link>
|
|
19
|
+
<button class="btn btn-danger" @click="handleDelete">{{ t('btn.delete', '삭제') }}</button>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="post-meta">
|
|
24
|
+
<span>{{ author?.name || '-' }}</span>
|
|
25
|
+
<span>{{ post.created_at }}</span>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="post-content">{{ post.content }}</div>
|
|
29
|
+
|
|
30
|
+
<!-- ── 비디오 썸네일 본문 표시 ── -->
|
|
31
|
+
<template v-if="files.length">
|
|
32
|
+
<div v-for="file in videoFiles" :key="file.id" class="inline-video">
|
|
33
|
+
<img :src="file.thumbUrl" :alt="file.original_name" style="width:100%;max-height:500px;object-fit:contain;border-radius:var(--radius-sm, 8px)" />
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<!-- ── 첨부파일 섹션 ── -->
|
|
38
|
+
<div v-if="files.length" class="post-attachments">
|
|
39
|
+
<h3>📎 {{ t('label.attachments', '첨부파일') }} ({{ files.length }})</h3>
|
|
40
|
+
<div class="attachment-grid">
|
|
41
|
+
<div v-for="file in files" :key="file.id" class="attachment-card">
|
|
42
|
+
<!-- 이미지 -->
|
|
43
|
+
<a v-if="file.isImage" href="#" @click.prevent="openLightbox(file.url, 'image')">
|
|
44
|
+
<div class="attachment-thumb">
|
|
45
|
+
<img :src="file.thumbUrl || file.url" :alt="file.original_name" loading="lazy" />
|
|
46
|
+
</div>
|
|
47
|
+
<div class="attachment-info">
|
|
48
|
+
<span class="attachment-name" :title="file.original_name">🖼️ {{ file.original_name }}</span>
|
|
49
|
+
</div>
|
|
50
|
+
</a>
|
|
51
|
+
<!-- 비디오 -->
|
|
52
|
+
<a v-else-if="file.isVideo" href="#" @click.prevent="openLightbox(file.url, 'video')">
|
|
53
|
+
<div class="attachment-thumb">
|
|
54
|
+
<img v-if="file.thumbUrl" :src="file.thumbUrl" :alt="file.original_name" loading="lazy" />
|
|
55
|
+
<div v-else class="thumb-placeholder">🎬</div>
|
|
56
|
+
<div class="play-overlay">▶</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="attachment-info">
|
|
59
|
+
<span class="attachment-name" :title="file.original_name">🎬 {{ file.original_name }}</span>
|
|
60
|
+
</div>
|
|
61
|
+
</a>
|
|
62
|
+
<!-- 일반 파일 -->
|
|
63
|
+
<a v-else :href="file.url" class="attachment-download" download>
|
|
64
|
+
<div class="attachment-thumb">
|
|
65
|
+
<div class="thumb-placeholder">📄</div>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="attachment-info">
|
|
68
|
+
<span class="attachment-name" :title="file.original_name">📄 {{ file.original_name }}</span>
|
|
69
|
+
</div>
|
|
70
|
+
</a>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="post-footer">
|
|
76
|
+
<router-link to="/board" class="btn btn-outline">{{ t('btn.back', '← 목록') }}</router-link>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<!-- ── Lightbox ── -->
|
|
81
|
+
<Lightbox ref="lightbox" />
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
84
|
+
|
|
85
|
+
<script setup>
|
|
86
|
+
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
87
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
88
|
+
import { useApi } from '../composables/useApi.js';
|
|
89
|
+
import { useAuthStore } from '../stores/auth.js';
|
|
90
|
+
import { useLocale } from '../composables/useLocale.js';
|
|
91
|
+
import { useToast } from '../plugins/toast.js';
|
|
92
|
+
import { useAlert } from '../plugins/alert.js';
|
|
93
|
+
import Lightbox from '../components/Lightbox.vue';
|
|
94
|
+
|
|
95
|
+
const route = useRoute();
|
|
96
|
+
const router = useRouter();
|
|
97
|
+
const api = useApi();
|
|
98
|
+
const authStore = useAuthStore();
|
|
99
|
+
const { t } = useLocale();
|
|
100
|
+
const toast = useToast();
|
|
101
|
+
const fxAlert = useAlert();
|
|
102
|
+
|
|
103
|
+
const post = ref(null);
|
|
104
|
+
const author = ref(null);
|
|
105
|
+
const files = ref([]);
|
|
106
|
+
const lightbox = ref(null);
|
|
107
|
+
let pollingTimer = null;
|
|
108
|
+
|
|
109
|
+
/** 비디오 파일 중 썸네일이 있는 것만 */
|
|
110
|
+
const videoFiles = computed(() => files.value.filter(f => f.isVideo && f.thumbUrl));
|
|
111
|
+
|
|
112
|
+
/** 내가 작성한 글인지 */
|
|
113
|
+
const isOwner = computed(() => authStore.user?.id && post.value?.user_id === authStore.user.id);
|
|
114
|
+
|
|
115
|
+
/** 게시글 로드 */
|
|
116
|
+
async function loadPost() {
|
|
117
|
+
try {
|
|
118
|
+
const res = await api.get(`/api/posts/${route.params.id}`);
|
|
119
|
+
if (res) {
|
|
120
|
+
post.value = res.post || res;
|
|
121
|
+
author.value = res.author || null;
|
|
122
|
+
files.value = res.files || [];
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
console.error('[Board] load failed:', e);
|
|
126
|
+
}
|
|
127
|
+
// processing이면 폴링 시작
|
|
128
|
+
if (post.value?.status === 'processing') startPolling();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 삭제 처리 */
|
|
132
|
+
async function handleDelete() {
|
|
133
|
+
const ok = await fxAlert.confirm(t('alert.confirm', '확인'), t('board.confirm_delete', '삭제하시겠습니까?'));
|
|
134
|
+
if (!ok) return;
|
|
135
|
+
try {
|
|
136
|
+
await api.del(`/api/posts/${post.value.id}`);
|
|
137
|
+
toast.success(t('board.deleted', '글이 삭제되었습니다.'));
|
|
138
|
+
router.push('/board');
|
|
139
|
+
} catch (e) {
|
|
140
|
+
toast.error(t('error.delete_failed', '삭제에 실패했습니다.'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Lightbox 열기 */
|
|
145
|
+
function openLightbox(src, type) {
|
|
146
|
+
lightbox.value?.open(src, type);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Processing 상태 폴링 — SSR board/show.html 패턴 */
|
|
150
|
+
function startPolling() {
|
|
151
|
+
stopPolling();
|
|
152
|
+
pollingTimer = setInterval(async () => {
|
|
153
|
+
try {
|
|
154
|
+
const res = await api.get(`/api/posts/status?ids=${post.value.id}`);
|
|
155
|
+
if (res?.statuses && res.statuses[post.value.id] !== 'processing') {
|
|
156
|
+
stopPolling();
|
|
157
|
+
loadPost();
|
|
158
|
+
}
|
|
159
|
+
} catch { /* 무시 */ }
|
|
160
|
+
}, 5000);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stopPolling() {
|
|
164
|
+
if (pollingTimer) { clearInterval(pollingTimer); pollingTimer = null; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
onMounted(loadPost);
|
|
168
|
+
onUnmounted(stopPolling);
|
|
169
|
+
</script>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
BoardForm.vue — 게시글 작성/수정 페이지.
|
|
3
|
+
|
|
4
|
+
SSR board/form.html 완전 복제:
|
|
5
|
+
제목, 에디터 툴바 + 내용, 기존 첨부파일 (수정 모드),
|
|
6
|
+
새 파일 업로드 (드래그&드롭 + 진행률), XHR 업로드.
|
|
7
|
+
-->
|
|
8
|
+
<template>
|
|
9
|
+
<div class="container">
|
|
10
|
+
<div class="glass-card">
|
|
11
|
+
<h1 class="page-title">
|
|
12
|
+
{{ isEdit ? t('board.edit_title', '글 수정') : t('board.new_title', '새 글 작성') }}
|
|
13
|
+
</h1>
|
|
14
|
+
|
|
15
|
+
<form class="form" @submit.prevent="handleSubmit">
|
|
16
|
+
<div class="form-group">
|
|
17
|
+
<label for="title">{{ t('label.title', '제목') }}</label>
|
|
18
|
+
<input v-model="form.title" type="text" id="title" required />
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="form-group">
|
|
22
|
+
<label for="board-content">{{ t('label.content', '내용') }}</label>
|
|
23
|
+
<EditorToolbar target-id="board-content" />
|
|
24
|
+
<textarea
|
|
25
|
+
v-model="form.content"
|
|
26
|
+
id="board-content"
|
|
27
|
+
rows="14"
|
|
28
|
+
class="has-toolbar"
|
|
29
|
+
required
|
|
30
|
+
></textarea>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- ── 기존 첨부파일 (수정 모드) ── -->
|
|
34
|
+
<div v-if="isEdit && existingFiles.length" class="form-group">
|
|
35
|
+
<label>{{ t('board.attached_files', '기존 첨부파일') }}</label>
|
|
36
|
+
<div class="existing-files-grid">
|
|
37
|
+
<div v-for="file in existingFiles" :key="file.id" class="existing-file-card">
|
|
38
|
+
<div class="existing-file-thumb">
|
|
39
|
+
<img v-if="file.thumbUrl" :src="file.thumbUrl" :alt="file.original_name" loading="lazy" />
|
|
40
|
+
<img v-else-if="file.isImage" :src="file.url" :alt="file.original_name" loading="lazy" />
|
|
41
|
+
<div v-else-if="file.isVideo" class="thumb-placeholder">🎬</div>
|
|
42
|
+
<div v-else class="thumb-placeholder">📄</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="existing-file-info">
|
|
45
|
+
<span class="file-name" :title="file.original_name">{{ file.original_name }}</span>
|
|
46
|
+
<button type="button" class="file-remove" title="삭제" @click="deleteExistingFile(file.id)">✕</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- ── 새 파일 업로드 ── -->
|
|
53
|
+
<FileUpload
|
|
54
|
+
ref="fileUploader"
|
|
55
|
+
:label="t('label.attachments', '첨부파일')"
|
|
56
|
+
:drop-text="t('board.drop_files', '클릭 또는 파일을 여기에 드래그')"
|
|
57
|
+
:hint-text="t('board.file_hint', '이미지, 비디오, 문서 파일')"
|
|
58
|
+
@update:files="onFilesChange"
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<div class="form-actions">
|
|
62
|
+
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
|
63
|
+
{{ submitBtnText }}
|
|
64
|
+
</button>
|
|
65
|
+
<router-link to="/board" class="btn btn-outline">{{ t('btn.cancel', '취소') }}</router-link>
|
|
66
|
+
</div>
|
|
67
|
+
</form>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<script setup>
|
|
73
|
+
import { ref, reactive, computed, onMounted } from 'vue';
|
|
74
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
75
|
+
import { useApi } from '../composables/useApi.js';
|
|
76
|
+
import { useLocale } from '../composables/useLocale.js';
|
|
77
|
+
import { useToast } from '../plugins/toast.js';
|
|
78
|
+
import { useAlert } from '../plugins/alert.js';
|
|
79
|
+
import EditorToolbar from '../components/EditorToolbar.vue';
|
|
80
|
+
import FileUpload from '../components/FileUpload.vue';
|
|
81
|
+
|
|
82
|
+
const route = useRoute();
|
|
83
|
+
const router = useRouter();
|
|
84
|
+
const api = useApi();
|
|
85
|
+
const { t } = useLocale();
|
|
86
|
+
const toast = useToast();
|
|
87
|
+
const fxAlert = useAlert();
|
|
88
|
+
|
|
89
|
+
const isEdit = computed(() => !!route.params.id);
|
|
90
|
+
const form = reactive({ title: '', content: '' });
|
|
91
|
+
const existingFiles = ref([]);
|
|
92
|
+
const selectedFiles = ref(null);
|
|
93
|
+
const submitting = ref(false);
|
|
94
|
+
const fileUploader = ref(null);
|
|
95
|
+
|
|
96
|
+
const submitBtnText = computed(() => {
|
|
97
|
+
if (submitting.value) return '업로드 중...';
|
|
98
|
+
return isEdit.value ? t('btn.edit', '수정') : t('btn.create', '작성');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** 수정 모드 — 기존 데이터 로드 */
|
|
102
|
+
onMounted(async () => {
|
|
103
|
+
if (!isEdit.value) return;
|
|
104
|
+
try {
|
|
105
|
+
const res = await api.get(`/api/posts/${route.params.id}`);
|
|
106
|
+
if (res) {
|
|
107
|
+
const post = res.post || res;
|
|
108
|
+
form.title = post.title || '';
|
|
109
|
+
form.content = post.content || '';
|
|
110
|
+
existingFiles.value = res.files || [];
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error('[BoardForm] load failed:', e);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** 파일 선택 콜백 */
|
|
118
|
+
function onFilesChange(fileList) {
|
|
119
|
+
selectedFiles.value = fileList;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 기존 첨부파일 삭제 */
|
|
123
|
+
async function deleteExistingFile(fileId) {
|
|
124
|
+
const ok = await fxAlert.confirm(t('alert.confirm', '확인'), t('board.confirm_delete_file', '첨부파일을 삭제하시겠습니까?'));
|
|
125
|
+
if (!ok) return;
|
|
126
|
+
try {
|
|
127
|
+
await api.del(`/api/posts/attachment/${fileId}`);
|
|
128
|
+
existingFiles.value = existingFiles.value.filter(f => f.id !== fileId);
|
|
129
|
+
toast.success(t('board.file_deleted', '첨부파일이 삭제되었습니다.'));
|
|
130
|
+
} catch (e) {
|
|
131
|
+
toast.error(t('error.delete_failed', '삭제에 실패했습니다.'));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 폼 제출 — 파일이 있으면 XHR 업로드 (진행률) */
|
|
136
|
+
async function handleSubmit() {
|
|
137
|
+
// 수정 모드일 때 확인
|
|
138
|
+
if (isEdit.value) {
|
|
139
|
+
const ok = await fxAlert.confirm(t('alert.confirm', '확인'), t('board.confirm_edit', '수정하시겠습니까?'));
|
|
140
|
+
if (!ok) return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
submitting.value = true;
|
|
144
|
+
const hasFiles = selectedFiles.value && selectedFiles.value.length > 0;
|
|
145
|
+
|
|
146
|
+
const formData = new FormData();
|
|
147
|
+
formData.append('title', form.title);
|
|
148
|
+
formData.append('content', form.content);
|
|
149
|
+
|
|
150
|
+
if (hasFiles) {
|
|
151
|
+
for (let i = 0; i < selectedFiles.value.length; i++) {
|
|
152
|
+
formData.append('files', selectedFiles.value[i]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 파일 업로드: /board (non-API, ASP bypass — XHR plain POST)
|
|
157
|
+
// JSON only: /api/posts (ASP 암호화 — WASM client)
|
|
158
|
+
const uploadUrl = isEdit.value ? `/board/${route.params.id}` : '/board';
|
|
159
|
+
const apiUrl = isEdit.value ? `/api/posts/${route.params.id}` : '/api/posts';
|
|
160
|
+
const method = isEdit.value ? 'PUT' : 'POST';
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
if (hasFiles) {
|
|
164
|
+
// XHR 업로드 + 진행률
|
|
165
|
+
const result = await api.uploadWithProgress(uploadUrl, formData, (pct, speed) => {
|
|
166
|
+
fileUploader.value?.setProgress(pct, speed);
|
|
167
|
+
}, method);
|
|
168
|
+
fileUploader.value?.setComplete('업로드 완료!');
|
|
169
|
+
toast.success(isEdit.value ? t('board.updated', '글이 수정되었습니다.') : t('board.created', '글이 작성되었습니다.'));
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
router.push(isEdit.value ? `/board/${route.params.id}` : '/board');
|
|
172
|
+
}, 1500);
|
|
173
|
+
} else {
|
|
174
|
+
// 파일 없으면 일반 API 호출
|
|
175
|
+
if (isEdit.value) {
|
|
176
|
+
await api.put(apiUrl, { title: form.title, content: form.content });
|
|
177
|
+
toast.success(t('board.updated', '글이 수정되었습니다.'));
|
|
178
|
+
router.push(`/board/${route.params.id}`);
|
|
179
|
+
} else {
|
|
180
|
+
await api.post(apiUrl, { title: form.title, content: form.content });
|
|
181
|
+
toast.success(t('board.created', '글이 작성되었습니다.'));
|
|
182
|
+
router.push('/board');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
fileUploader.value?.setError('오류 발생');
|
|
187
|
+
toast.error(t('error.submit_failed', '저장에 실패했습니다.'));
|
|
188
|
+
} finally {
|
|
189
|
+
submitting.value = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
</script>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
BoardList.vue — 게시판 목록 페이지.
|
|
3
|
+
|
|
4
|
+
SSR board/index.html 완전 복제:
|
|
5
|
+
게시글 목록 (썸네일 + 제목 + processing 뱃지 + 메타),
|
|
6
|
+
페이지네이션, processing 상태 REST 폴링.
|
|
7
|
+
-->
|
|
8
|
+
<template>
|
|
9
|
+
<div class="container">
|
|
10
|
+
<div class="glass-card">
|
|
11
|
+
<div class="page-header">
|
|
12
|
+
<h1 class="page-title">{{ t('board.title', '게시판') }}</h1>
|
|
13
|
+
<router-link to="/board/new" class="btn btn-primary">{{ t('btn.new_post', '새 글') }}</router-link>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div class="post-list">
|
|
17
|
+
<router-link
|
|
18
|
+
v-for="post in posts"
|
|
19
|
+
:key="post.id"
|
|
20
|
+
:to="`/board/${post.id}`"
|
|
21
|
+
class="post-card"
|
|
22
|
+
>
|
|
23
|
+
<div v-if="post.thumbUrl" class="post-card-thumb">
|
|
24
|
+
<img :src="post.thumbUrl" :alt="post.title" loading="lazy" />
|
|
25
|
+
</div>
|
|
26
|
+
<div class="post-card-body">
|
|
27
|
+
<h3 class="post-card-title">
|
|
28
|
+
<span v-if="post.status === 'processing'" class="badge badge-processing">🔄 {{ t('board.processing', '처리중') }}</span>
|
|
29
|
+
{{ post.title }}
|
|
30
|
+
</h3>
|
|
31
|
+
<div class="post-card-meta">
|
|
32
|
+
<span>{{ post.user_name || '-' }}</span>
|
|
33
|
+
<span>{{ post.created_at }}</span>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</router-link>
|
|
37
|
+
|
|
38
|
+
<div v-if="!posts.length && !loading" class="post-list-empty">
|
|
39
|
+
{{ t('board.empty', '게시글이 없습니다.') }}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<Pagination
|
|
44
|
+
:page="page"
|
|
45
|
+
:last-page="lastPage"
|
|
46
|
+
@change="onPageChange"
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script setup>
|
|
53
|
+
import { ref, onMounted, onUnmounted } from 'vue';
|
|
54
|
+
import { useRouter, useRoute } from 'vue-router';
|
|
55
|
+
import { useApi } from '../composables/useApi.js';
|
|
56
|
+
import { useLocale } from '../composables/useLocale.js';
|
|
57
|
+
import Pagination from '../components/Pagination.vue';
|
|
58
|
+
|
|
59
|
+
const router = useRouter();
|
|
60
|
+
const route = useRoute();
|
|
61
|
+
const api = useApi();
|
|
62
|
+
const { t } = useLocale();
|
|
63
|
+
|
|
64
|
+
const posts = ref([]);
|
|
65
|
+
const page = ref(1);
|
|
66
|
+
const lastPage = ref(1);
|
|
67
|
+
const loading = ref(false);
|
|
68
|
+
let pollingTimer = null;
|
|
69
|
+
|
|
70
|
+
/** 게시글 목록 로드 */
|
|
71
|
+
async function loadPosts() {
|
|
72
|
+
loading.value = true;
|
|
73
|
+
try {
|
|
74
|
+
const res = await api.get(`/api/posts?page=${page.value}`);
|
|
75
|
+
if (res) {
|
|
76
|
+
posts.value = res.data || res.posts || [];
|
|
77
|
+
page.value = res.page || 1;
|
|
78
|
+
lastPage.value = res.lastPage || 1;
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.error('[Board] load failed:', e);
|
|
82
|
+
} finally {
|
|
83
|
+
loading.value = false;
|
|
84
|
+
}
|
|
85
|
+
// processing 게시글 있으면 폴링 시작
|
|
86
|
+
startPolling();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 페이지 변경 */
|
|
90
|
+
function onPageChange(newPage) {
|
|
91
|
+
page.value = newPage;
|
|
92
|
+
loadPosts();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Processing 상태 REST 폴링 — SSR board/index.html 패턴 */
|
|
96
|
+
function startPolling() {
|
|
97
|
+
stopPolling();
|
|
98
|
+
const processingIds = posts.value
|
|
99
|
+
.filter(p => p.status === 'processing')
|
|
100
|
+
.map(p => p.id);
|
|
101
|
+
if (!processingIds.length) return;
|
|
102
|
+
|
|
103
|
+
pollingTimer = setInterval(async () => {
|
|
104
|
+
try {
|
|
105
|
+
const res = await api.get(`/api/posts/status?ids=${processingIds.join(',')}`);
|
|
106
|
+
if (!res?.statuses) return;
|
|
107
|
+
let allDone = true;
|
|
108
|
+
for (const id of processingIds) {
|
|
109
|
+
if (res.statuses[id] === 'processing') { allDone = false; break; }
|
|
110
|
+
}
|
|
111
|
+
if (allDone) {
|
|
112
|
+
stopPolling();
|
|
113
|
+
loadPosts();
|
|
114
|
+
}
|
|
115
|
+
} catch { /* 무시 */ }
|
|
116
|
+
}, 5000);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stopPolling() {
|
|
120
|
+
if (pollingTimer) { clearInterval(pollingTimer); pollingTimer = null; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
onMounted(() => {
|
|
124
|
+
page.value = parseInt(route.query.page) || 1;
|
|
125
|
+
loadPosts();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
onUnmounted(stopPolling);
|
|
129
|
+
</script>
|