@fuzionx/framework 0.1.46 → 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 (87) 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/package.json +2 -2
@@ -0,0 +1,31 @@
1
+ import { auth } from '@fuzionx/framework';
2
+ import AuthController from '../controllers/AuthController.js';
3
+ import UserController from '../controllers/UserController.js';
4
+ import PostController from '../controllers/PostController.js';
5
+
6
+ export default (r) => {
7
+ // ── Auth API (인증 불필요) ──
8
+ r.post('/api/auth/login', AuthController.login);
9
+ r.post('/api/auth/register', AuthController.register);
10
+ r.post('/api/auth/logout', AuthController.logout);
11
+ r.get('/api/auth/check', AuthController.check);
12
+
13
+ // ── Protected API (인증 필요) ──
14
+ r.group('/api', { middleware: [auth()] }, (r) => {
15
+ // 프로필
16
+ r.get('/user/profile', UserController.profile);
17
+ r.put('/user/profile', UserController.updateProfile);
18
+
19
+ // 게시판 (RESTful resource)
20
+ r.resource('posts', PostController);
21
+
22
+ // 게시글 상태 폴링 (processing 관련)
23
+ r.get('/posts/status', PostController.status);
24
+
25
+ // 개별 첨부파일 삭제
26
+ r.delete('/posts/attachment/:id', PostController.deleteAttachment);
27
+
28
+ // 하트비트 (세션 연장)
29
+ r.get('/heartbeat', AuthController.heartbeat);
30
+ });
31
+ };
@@ -0,0 +1,19 @@
1
+ import { auth } from '@fuzionx/framework';
2
+ import HomeController from '../controllers/HomeController.js';
3
+ import PostController from '../controllers/PostController.js';
4
+
5
+ export default (r) => {
6
+ // SPA Shell — 모든 프론트엔드 라우트에 대해 Vue 앱 서빙
7
+ // Vue Router가 클라이언트에서 login, register, board 등 처리
8
+ r.get('/', HomeController.index);
9
+
10
+ // ── 파일 업로드 (ASP bypass — /api 가 아닌 경로) ──
11
+ // XHR uploadWithProgress는 plain POST → ASP 헤더 없음
12
+ // /api 경로는 ASP 필수이므로 /board 경로로 우회
13
+ r.group('', { middleware: [auth()] }, (r) => {
14
+ r.post('/board', PostController.store);
15
+ r.post('/board/:id', PostController.update);
16
+ });
17
+
18
+ r.get('/*', HomeController.index);
19
+ };
@@ -0,0 +1,48 @@
1
+ import { Service } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * AuthService — SPA 인증 서비스
5
+ *
6
+ * 회원가입/로그인 비즈니스 로직 처리.
7
+ * bcrypt 해시를 사용한 비밀번호 암호화 및 검증.
8
+ *
9
+ * @extends Service
10
+ */
11
+ export default class AuthService extends Service {
12
+ /**
13
+ * 회원가입 — 신규 사용자 생성
14
+ *
15
+ * 이메일 중복 검사 후 bcrypt 해시 비밀번호로 사용자 생성.
16
+ *
17
+ * @param {Object} data - 가입 정보
18
+ * @param {string} data.name - 사용자 이름
19
+ * @param {string} data.email - 이메일 주소
20
+ * @param {string} data.password - 비밀번호 (평문 → bcrypt 해시 변환)
21
+ * @returns {Promise<import('../../../database/models/User.js').default>} 생성된 사용자
22
+ * @throws {AppError} 409 — 이메일 중복 시
23
+ */
24
+ async register({ name, email, password }) {
25
+ const existing = await this.db.User.where('email', email).first();
26
+ if (existing) throw this.error('auth.email_exists', 409);
27
+
28
+ const hash = this.app.hash.bcrypt(password);
29
+ return this.db.User.create({ name, email, password: hash, role: 'user' });
30
+ }
31
+
32
+ /**
33
+ * 로그인 — 이메일/비밀번호 검증
34
+ *
35
+ * 이메일로 사용자 조회 후 bcrypt 비밀번호 대조.
36
+ *
37
+ * @param {Object} credentials - 로그인 정보
38
+ * @param {string} credentials.email - 이메일 주소
39
+ * @param {string} credentials.password - 비밀번호 (평문)
40
+ * @returns {Promise<import('../../../database/models/User.js').default|null>} 인증된 사용자 또는 null
41
+ */
42
+ async login({ email, password }) {
43
+ const user = await this.db.User.where('email', email).first();
44
+ if (!user) return null;
45
+ if (!this.app.hash.bcryptVerify(password, user.password)) return null;
46
+ return user;
47
+ }
48
+ }
@@ -0,0 +1,372 @@
1
+ import { Service } from '@fuzionx/framework';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * PostService — SSR 게시글 + 첨부파일 + 썸네일 서비스
7
+ *
8
+ * ## 주요 기능
9
+ * - 게시글 CRUD (list, find, create, update, remove)
10
+ * - 첨부파일 저장/삭제 (Storage API)
11
+ * - 썸네일 자동 생성 (이미지: resize, 비디오: frame 추출)
12
+ *
13
+ * ## 썸네일 생성 전략
14
+ * - **이미지 파일**: `app.media.resize()` → 400x300 webp 썸네일
15
+ * - **비디오 파일**: `app.media.videoThumbnail()` → 3초 지점 프레임 추출 → jpeg
16
+ * - 썸네일은 `storage/uploads/thumbs/{attachmentId}.webp` 에 저장
17
+ * - Thumbnail 모델에 메타데이터(경로, 크기, 포맷, source_type) 기록
18
+ *
19
+ * ## 사용 예시
20
+ * ```js
21
+ * // 컨트롤러에서
22
+ * const post = await this.service('PostService').create(
23
+ * { title: '제목', content: '내용', user_id: ctx.user.id },
24
+ * ctx.files, // Bridge 업로드 파일 배열
25
+ * );
26
+ *
27
+ * // 첨부파일 + 썸네일 조회
28
+ * const files = await this.service('PostService').getAttachmentsWithThumbs(post.id);
29
+ * // → [{ ...attachment, thumbUrl, isImage, isVideo }]
30
+ * ```
31
+ *
32
+ * @extends Service
33
+ */
34
+ export default class PostService extends Service {
35
+
36
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
37
+ // 게시글 CRUD
38
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
+
40
+ /**
41
+ * 게시글 목록 조회 (페이지네이션)
42
+ *
43
+ * @param {number} [page=1] - 현재 페이지 번호
44
+ * @param {number} [perPage=10] - 페이지당 게시글 수
45
+ * @returns {Promise<{ data: Post[], page, lastPage, hasMore, total }>}
46
+ */
47
+ async list(page = 1, perPage = 10) {
48
+ return this.db.Post.query()
49
+ .orderBy('created_at', 'desc')
50
+ .paginate(page, perPage);
51
+ }
52
+
53
+ /**
54
+ * 게시글 단건 조회
55
+ *
56
+ * @param {number} id - 게시글 ID
57
+ * @returns {Promise<Post|null>}
58
+ */
59
+ async find(id) {
60
+ return this.db.Post.find(id);
61
+ }
62
+
63
+ /**
64
+ * 게시글 생성 + 첨부파일 + 썸네일
65
+ *
66
+ * @param {Object} data - { title, content, user_id }
67
+ * @param {Array} [files=[]] - Bridge 업로드 파일 배열 [{fieldName, originalName, mimeType, size, tempPath}]
68
+ * @returns {Promise<Post>}
69
+ *
70
+ * @example
71
+ * const post = await postService.create(
72
+ * { title: 'Hello', content: 'World', user_id: 1 },
73
+ * ctx.files,
74
+ * );
75
+ */
76
+ async create(data, files = []) {
77
+ // 비디오 포함 여부에 따라 초기 status 결정
78
+ const hasVideo = files?.some(f => f.mimeType?.startsWith('video/'));
79
+ data.status = hasVideo ? 'processing' : 'published';
80
+ const post = await this.db.Post.create(data);
81
+ if (files?.length) {
82
+ await this._processFiles(post.id, files);
83
+ }
84
+ return post;
85
+ }
86
+
87
+ /**
88
+ * 게시글 수정 + 새 첨부파일 추가
89
+ *
90
+ * @param {number} id - 게시글 ID
91
+ * @param {Object} data - { title, content }
92
+ * @param {Array} [files=[]] - 새로 추가할 파일
93
+ * @returns {Promise<Post>}
94
+ */
95
+ async update(id, data, files = []) {
96
+ const post = await this.db.Post.find(id);
97
+ if (!post) throw this.error('Post not found', 404);
98
+ await post.update(data);
99
+ if (files?.length) {
100
+ const hasVideo = files.some(f => f.mimeType?.startsWith('video/'));
101
+ if (hasVideo) await post.update({ status: 'processing' });
102
+ await this._processFiles(post.id, files);
103
+ }
104
+ return post;
105
+ }
106
+
107
+ /**
108
+ * 게시글 삭제 + 모든 첨부파일/썸네일 삭제
109
+ *
110
+ * 삭제 순서: 썸네일 → 첨부파일(Storage + DB) → 게시글
111
+ *
112
+ * @param {number} id - 게시글 ID
113
+ * @returns {Promise<void>}
114
+ */
115
+ async remove(id) {
116
+ const post = await this.db.Post.find(id);
117
+ if (!post) throw this.error('Post not found', 404);
118
+ await this._deleteAllFiles(id);
119
+ await post.delete();
120
+ }
121
+
122
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
123
+ // 첨부파일 + 썸네일 조회
124
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
125
+
126
+ /**
127
+ * 게시글의 첨부파일 목록 + 썸네일 URL 포함
128
+ *
129
+ * 반환 객체에 추가되는 필드:
130
+ * - `url` : 원본 파일 공개 URL
131
+ * - `thumbUrl` : 썸네일 URL (없으면 null)
132
+ * - `isImage` : MIME이 image/* 인지 여부
133
+ * - `isVideo` : MIME이 video/* 인지 여부
134
+ *
135
+ * @param {number} postId - 게시글 ID
136
+ * @returns {Promise<Array<Object>>}
137
+ *
138
+ * @example
139
+ * const files = await postService.getAttachmentsWithThumbs(postId);
140
+ * // → [{ id, original_name, url, thumbUrl, isImage, isVideo, size, mime_type }]
141
+ */
142
+ async getAttachmentsWithThumbs(postId) {
143
+ const attachments = await this.db.Attachment.where('post_id', postId).get();
144
+ if (!attachments.length) return [];
145
+
146
+ // 모든 썸네일을 한 번에 조회 (N+1 방지)
147
+ const attIds = attachments.map(a => a.id);
148
+ const thumbnails = await this.db.Thumbnail.query()
149
+ .whereIn('attachment_id', attIds)
150
+ .get();
151
+
152
+ // attachment_id → thumbnail 매핑
153
+ const thumbMap = new Map();
154
+ for (const t of thumbnails) {
155
+ thumbMap.set(t.attachment_id, t);
156
+ }
157
+
158
+ return attachments.map(att => {
159
+ const thumb = thumbMap.get(att.id);
160
+ return {
161
+ ...att,
162
+ url: this.app.storage.url(att.file_path),
163
+ thumbUrl: thumb ? this.app.storage.url(thumb.file_path) : null,
164
+ isImage: att.mime_type?.startsWith('image/'),
165
+ isVideo: att.mime_type?.startsWith('video/'),
166
+ };
167
+ });
168
+ }
169
+
170
+ /**
171
+ * 게시글 목록용 — 각 게시글의 대표 썸네일 1개 조회
172
+ *
173
+ * 첫 번째 첨부파일의 썸네일을 대표 이미지로 사용.
174
+ * 게시글 목록 페이지에서 미리보기 이미지 제공용.
175
+ *
176
+ * @param {number[]} postIds - 게시글 ID 배열
177
+ * @returns {Promise<Map<number, string|null>>} postId → thumbUrl
178
+ *
179
+ * @example
180
+ * const thumbMap = await postService.getPostThumbnails([1, 2, 3]);
181
+ * // → Map { 1 => '/storage/uploads/thumbs/5.webp', 2 => null, 3 => '...' }
182
+ */
183
+ async getPostThumbnails(postIds) {
184
+ if (!postIds.length) return new Map();
185
+
186
+ // 각 게시글의 첫 첨부파일 조회
187
+ const attachments = await this.db.Attachment.query()
188
+ .whereIn('post_id', postIds)
189
+ .orderBy('id', 'asc')
190
+ .get();
191
+
192
+ // postId → 첫 attachment 매핑
193
+ const firstAttMap = new Map();
194
+ for (const att of attachments) {
195
+ if (!firstAttMap.has(att.post_id)) {
196
+ firstAttMap.set(att.post_id, att);
197
+ }
198
+ }
199
+
200
+ // 첫 첨부파일들의 썸네일 조회
201
+ const attIds = [...firstAttMap.values()].map(a => a.id);
202
+ if (!attIds.length) return new Map();
203
+
204
+ const thumbnails = await this.db.Thumbnail.query()
205
+ .whereIn('attachment_id', attIds)
206
+ .get();
207
+
208
+ const thumbByAtt = new Map();
209
+ for (const t of thumbnails) {
210
+ thumbByAtt.set(t.attachment_id, t);
211
+ }
212
+
213
+ // postId → thumbUrl 매핑
214
+ const result = new Map();
215
+ for (const pid of postIds) {
216
+ const att = firstAttMap.get(pid);
217
+ if (att) {
218
+ const thumb = thumbByAtt.get(att.id);
219
+ result.set(pid, thumb ? this.app.storage.url(thumb.file_path) : null);
220
+ } else {
221
+ result.set(pid, null);
222
+ }
223
+ }
224
+ return result;
225
+ }
226
+
227
+ /**
228
+ * 특정 첨부파일 삭제 (썸네일 포함)
229
+ *
230
+ * @param {number} attachmentId - 첨부파일 ID
231
+ * @returns {Promise<void>}
232
+ */
233
+ async removeAttachment(attachmentId) {
234
+ const att = await this.db.Attachment.find(attachmentId);
235
+ if (!att) return;
236
+
237
+ // 썸네일 삭제
238
+ await this._deleteThumbnail(attachmentId);
239
+
240
+ // 원본 파일 삭제
241
+ try { await this.app.storage.delete(att.file_path); } catch {}
242
+ await att.delete();
243
+ }
244
+
245
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
246
+ // Private — 파일 처리 + 썸네일 생성
247
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
248
+
249
+ /**
250
+ * 파일 저장 + 썸네일 생성 (이미지/비디오 자동 감지)
251
+ *
252
+ * 처리 흐름:
253
+ * 1. 임시파일 → Storage 영구 저장 (`uploads/posts/{postId}/xxx.ext`)
254
+ * 2. Attachment DB 레코드 생성
255
+ * 3. 이미지인 경우 → `app.media.resize()` 로 썸네일 생성
256
+ * 4. 비디오인 경우 → `app.media.videoThumbnail()` 로 프레임 추출
257
+ * 5. Thumbnail DB 레코드 생성
258
+ *
259
+ * @private
260
+ * @param {number} postId - 게시글 ID
261
+ * @param {Array} files - [{fieldName, originalName, mimeType, size, tempPath}]
262
+ */
263
+ async _processFiles(postId, files) {
264
+ for (const f of files) {
265
+ // 1. 원본 파일 저장
266
+ const ext = f.originalName.split('.').pop() || 'bin';
267
+ const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
268
+ const storagePath = `uploads/posts/${postId}/${uid}.${ext}`;
269
+ await this.app.storage.put(storagePath, f.tempPath);
270
+
271
+ // 2. Attachment 레코드
272
+ const att = await this.db.Attachment.create({
273
+ post_id: postId,
274
+ original_name: f.originalName,
275
+ file_path: storagePath,
276
+ mime_type: f.mimeType,
277
+ size: f.size,
278
+ });
279
+
280
+ // 3. 썸네일 생성 (이미지 or 비디오)
281
+ const isImage = f.mimeType?.startsWith('image/');
282
+ const isVideo = f.mimeType?.startsWith('video/');
283
+
284
+ if (isImage) {
285
+ await this._generateImageThumbnail(att, storagePath);
286
+ } else if (isVideo) {
287
+ // 비디오 썸네일은 백그라운드 Task로 위임
288
+ this.app.dispatch('ProcessVideoThumbnailTask', {
289
+ postId,
290
+ attachmentId: att.id,
291
+ storagePath,
292
+ });
293
+ }
294
+ }
295
+ }
296
+
297
+ /**
298
+ * 이미지 썸네일 생성
299
+ *
300
+ * `app.media.resize()` 호출:
301
+ * - 입력: Storage 원본 이미지
302
+ * - 출력: 400px 가로 webp (비율 유지, 높이 자동)
303
+ * - 품질: 80
304
+ *
305
+ * @private
306
+ * @param {Object} att - Attachment 레코드
307
+ * @param {string} storagePath - 원본 Storage 경로
308
+ */
309
+ async _generateImageThumbnail(att, storagePath) {
310
+ try {
311
+ const fullPath = path.join(this.app.storage.basePath, storagePath);
312
+ const thumbName = `${att.id}.webp`;
313
+ const thumbStoragePath = `uploads/thumbs/${thumbName}`;
314
+ const thumbFullPath = path.join(this.app.storage.basePath, thumbStoragePath);
315
+
316
+ // 썸네일 디렉토리 생성
317
+ await fs.mkdir(path.dirname(thumbFullPath), { recursive: true });
318
+
319
+ // Bridge 이미지 리사이즈 (Rust native — width:400, height:0 = 비율 유지)
320
+ this.app.media.resize(fullPath, thumbFullPath, 400, 0, 'webp', 80);
321
+
322
+ // 썸네일 파일 정보
323
+ const stat = await fs.stat(thumbFullPath);
324
+ let info = { width: 400, height: 0 };
325
+ try { info = this.app.media.imageInfo(thumbFullPath); } catch {}
326
+
327
+ // Thumbnail DB 레코드
328
+ await this.db.Thumbnail.create({
329
+ attachment_id: att.id,
330
+ file_path: thumbStoragePath,
331
+ width: info.width || 400,
332
+ height: info.height || 0,
333
+ format: 'webp',
334
+ size: stat.size,
335
+ source_type: 'image',
336
+ });
337
+ } catch (err) {
338
+ console.warn(`[PostService] 이미지 썸네일 생성 실패 (att:${att.id}):`, err.message);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * 특정 첨부파일의 썸네일 삭제 (Storage + DB)
344
+ *
345
+ * @private
346
+ * @param {number} attachmentId
347
+ */
348
+ async _deleteThumbnail(attachmentId) {
349
+ const thumbs = await this.db.Thumbnail.where('attachment_id', attachmentId).get();
350
+ for (const t of thumbs) {
351
+ try { await this.app.storage.delete(t.file_path); } catch {}
352
+ await t.delete();
353
+ }
354
+ }
355
+
356
+ /**
357
+ * 게시글의 모든 첨부파일 + 썸네일 삭제
358
+ *
359
+ * @private
360
+ * @param {number} postId
361
+ */
362
+ async _deleteAllFiles(postId) {
363
+ const attachments = await this.db.Attachment.where('post_id', postId).get();
364
+ for (const att of attachments) {
365
+ // 썸네일 먼저 삭제
366
+ await this._deleteThumbnail(att.id);
367
+ // 원본 파일 삭제
368
+ try { await this.app.storage.delete(att.file_path); } catch {}
369
+ await att.delete();
370
+ }
371
+ }
372
+ }
@@ -0,0 +1,48 @@
1
+ import { Service } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * UserService — SPA 사용자 서비스
5
+ *
6
+ * 사용자 프로필 조회 및 수정 비즈니스 로직 처리.
7
+ * 비밀번호 변경 시 bcrypt 해시 적용.
8
+ *
9
+ * @extends Service
10
+ */
11
+ export default class UserService extends Service {
12
+ /**
13
+ * 사용자 단건 조회
14
+ *
15
+ * @param {number} id - 사용자 ID
16
+ * @returns {Promise<import('../../../database/models/User.js').default|null>} 사용자 또는 null
17
+ */
18
+ async find(id) {
19
+ return this.db.User.find(id);
20
+ }
21
+
22
+ /**
23
+ * 사용자 프로필 수정
24
+ *
25
+ * name, email, password 중 변경된 필드만 업데이트.
26
+ * password가 있을 경우 bcrypt 해시 후 저장.
27
+ *
28
+ * @param {number} id - 사용자 ID
29
+ * @param {Object} data - 수정할 데이터
30
+ * @param {string} [data.name] - 이름
31
+ * @param {string} [data.email] - 이메일
32
+ * @param {string} [data.password] - 새 비밀번호 (평문 → bcrypt 해시)
33
+ * @returns {Promise<import('../../../database/models/User.js').default>} 수정된 사용자
34
+ * @throws {AppError} 404 — 사용자 없음
35
+ */
36
+ async update(id, data) {
37
+ const user = await this.db.User.find(id);
38
+ if (!user) throw this.error('User not found', 404);
39
+
40
+ const updates = {};
41
+ if (data.name) updates.name = data.name;
42
+ if (data.email) updates.email = data.email;
43
+ if (data.password) updates.password = this.app.hash.bcrypt(data.password);
44
+
45
+ await user.update(updates);
46
+ return user;
47
+ }
48
+ }
@@ -0,0 +1,11 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}404{% endblock %}
3
+ {% block content %}
4
+ <div class="auth-container">
5
+ <div class="glass-card auth-card">
6
+ <div class="auth-logo">404</div>
7
+ <p class="auth-subtitle">{{ t(key="page.404", default="페이지를 찾을 수 없습니다") }}</p>
8
+ <a href="/" class="btn btn-primary btn-full">{{ t(key="btn.go_home", default="홈으로") }}</a>
9
+ </div>
10
+ </div>
11
+ {% endblock %}
@@ -0,0 +1,11 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}500{% endblock %}
3
+ {% block content %}
4
+ <div class="auth-container">
5
+ <div class="glass-card auth-card">
6
+ <div class="auth-logo">500</div>
7
+ <p class="auth-subtitle">{{ t(key="page.500", default="서버 오류가 발생했습니다") }}</p>
8
+ <a href="/" class="btn btn-primary btn-full">{{ t(key="btn.go_home", default="홈으로") }}</a>
9
+ </div>
10
+ </div>
11
+ {% endblock %}
@@ -0,0 +1,34 @@
1
+ <!DOCTYPE html>
2
+ <html lang="{{ locale | default(value='ko') }}">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}FuzionX{% endblock %}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="/public/css/style.css">
10
+ {% block head %}{% endblock %}
11
+ </head>
12
+ <body>
13
+ <div class="orb orb-1"></div>
14
+ <div class="orb orb-2"></div>
15
+ <div class="orb orb-3"></div>
16
+
17
+ {% if flash.error %}
18
+ <div class="container" style="padding-top: 1rem">
19
+ <div class="alert alert-error">{{ flash.error }}</div>
20
+ </div>
21
+ {% endif %}
22
+ {% if flash.success %}
23
+ <div class="container" style="padding-top: 1rem">
24
+ <div class="alert alert-success">{{ flash.success }}</div>
25
+ </div>
26
+ {% endif %}
27
+
28
+ <main class="main-content">
29
+ {% block content %}{% endblock %}
30
+ </main>
31
+
32
+ {% block scripts %}{% endblock %}
33
+ </body>
34
+ </html>
@@ -0,0 +1,22 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}FuzionX SPA{% endblock %}
3
+
4
+ {% block head %}
5
+ {% if isDev %}
6
+ <script type="module" src="http://localhost:5173/@vite/client"></script>
7
+ <script type="module" src="http://localhost:5173/src/main.js"></script>
8
+ {% else %}
9
+ <script type="module" src="/public/dist/assets/main.js"></script>
10
+ <link rel="stylesheet" href="/public/dist/assets/main.css">
11
+ {% endif %}
12
+ {% endblock %}
13
+
14
+ {% block content %}
15
+ <div id="app"></div>
16
+
17
+ <script>
18
+ window.__FX_CLIENT_SECRET__ = "{{ client_secret | safe }}";
19
+ window.__FX__ = "{{ __fx__ | safe }}";
20
+ window.__FX__ENC_ENABLED = {{ __fx__enabled | safe }}
21
+ </script>
22
+ {% endblock %}
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>FuzionX SPA</title>
7
+ <meta name="description" content="FuzionX — Rust-powered Node.js framework for the modern web." />
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "fuzionx-spa",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "pinia": "^3.0.4",
13
+ "vue": "^3.5.0",
14
+ "vue-router": "^4.5.0"
15
+ },
16
+ "devDependencies": {
17
+ "@vitejs/plugin-vue": "^5.2.0",
18
+ "vite": "^6.0.0"
19
+ }
20
+ }
@@ -0,0 +1,41 @@
1
+ <!--
2
+ App.vue — SPA 루트 컴포넌트.
3
+
4
+ SSR main.html 레이아웃을 Vue 컴포넌트로 구현:
5
+ 배경 orbs + Navbar (항상 표시) + FlashMessage + router-view.
6
+ -->
7
+ <template>
8
+ <div class="app-wrapper">
9
+ <!-- 배경 Orbs — SSR main.html body -->
10
+ <div class="orb orb-1"></div>
11
+ <div class="orb orb-2"></div>
12
+ <div class="orb orb-3"></div>
13
+
14
+ <!-- 네비게이션 바 — 항상 표시 -->
15
+ <Navbar />
16
+
17
+ <main class="main-content has-nav">
18
+ <!-- Flash 메시지 — SSR flash.error / flash.success -->
19
+ <FlashMessage />
20
+ <router-view />
21
+ </main>
22
+
23
+ <!-- 글로벌 Toast + Alert -->
24
+ <ToastContainer />
25
+ <AlertDialog />
26
+ </div>
27
+ </template>
28
+
29
+ <script setup>
30
+ import { useAuthStore } from './stores/auth.js';
31
+ import { useHeartbeat } from './composables/useHeartbeat.js';
32
+ import Navbar from './components/Navbar.vue';
33
+ import FlashMessage from './components/FlashMessage.vue';
34
+ import ToastContainer from './components/ToastContainer.vue';
35
+ import AlertDialog from './components/AlertDialog.vue';
36
+
37
+ const authStore = useAuthStore();
38
+
39
+ // 인증된 사용자만 하트비트 시작
40
+ useHeartbeat();
41
+ </script>