@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,138 @@
1
+ import { WsHandler } from '@fuzionx/framework';
2
+
3
+ /** 채팅 WebSocket 핸들러 */
4
+ export default class ChatHandler extends WsHandler {
5
+ static namespace = '/chat';
6
+ static middleware = ['auth'];
7
+
8
+ /**
9
+ * disconnect 시 metadata가 이미 삭제되므로,
10
+ * setUser 시점에 이름을 캐시하여 disconnect에서 사용.
11
+ * 연결/해제는 항상 같은 워커에서 발생하므로 per-worker 캐시로 충분.
12
+ */
13
+ static _nameCache = new Map();
14
+
15
+ static events(e) {
16
+ e.on('message', ChatHandler.prototype.handleMessage);
17
+ e.on('broadcast', ChatHandler.prototype.handleBroadcast);
18
+ e.on('typing', ChatHandler.prototype.handleTyping);
19
+ e.on('userlist', ChatHandler.prototype.handleUserList);
20
+ e.on('setUser', ChatHandler.prototype.handleSetUser);
21
+ }
22
+
23
+ /* ── helpers ── */
24
+
25
+ /** 사용자 이름 조회 (캐시 → metadata → UUID 폴백) */
26
+ _getName(socket, sid) {
27
+ return ChatHandler._nameCache.get(sid)
28
+ || socket.getMetadataFor(sid, 'name')
29
+ || sid;
30
+ }
31
+
32
+ /** 단일 사용자 정보 빌드 */
33
+ _buildUserInfo(socket, sid) {
34
+ return {
35
+ sid,
36
+ name: this._getName(socket, sid),
37
+ email: socket.getMetadataFor(sid, 'email') || '',
38
+ };
39
+ }
40
+
41
+ /** 전체 접속자 목록 빌드 */
42
+ _buildUserList(socket) {
43
+ const sessions = socket.sessionIds;
44
+ return {
45
+ count: socket.onlineCount,
46
+ users: sessions.map(sid => this._buildUserInfo(socket, sid)),
47
+ };
48
+ }
49
+
50
+ /* ── lifecycle ── */
51
+
52
+ async onConnect(socket) {
53
+ const sid = socket.sessionId;
54
+ console.log(`[Chat] 연결: ${sid}`);
55
+
56
+ socket.send(JSON.stringify({
57
+ type: 'chat_ready',
58
+ data: { sessionId: sid, timestamp: Date.now() },
59
+ }));
60
+ }
61
+
62
+ async onDisconnect(socket, code, reason) {
63
+ const sid = socket.sessionId;
64
+ const name = ChatHandler._nameCache.get(sid);
65
+ ChatHandler._nameCache.delete(sid);
66
+
67
+ // setUser 미완료 세션은 user_joined도 안 보냈으므로 user_left 불필요
68
+ if (!name) return;
69
+
70
+ console.log(`[Chat] 해제: ${name} code=${code ?? 'N/A'}`);
71
+ socket.broadcastExcluding(JSON.stringify({
72
+ type: 'user_left',
73
+ data: { sid, name, timestamp: Date.now() },
74
+ }));
75
+ }
76
+
77
+ /* ── event handlers ── */
78
+
79
+ /** setUser — 사용자 정보 등록 + userlist 응답 */
80
+ async handleSetUser(socket, data) {
81
+ const sid = socket.sessionId;
82
+ const name = (data.name || '').trim() || sid;
83
+
84
+ // Bridge metadata + 로컬 캐시 동시 저장
85
+ socket.setMetadata('name', name);
86
+ if (data.email) socket.setMetadata('email', data.email);
87
+ if (data.id) socket.setMetadata('userId', String(data.id));
88
+ ChatHandler._nameCache.set(sid, name);
89
+ console.log(`[Chat] 사용자 등록: ${sid} → ${name}`);
90
+
91
+ // 다른 사용자에게 참여 알림
92
+ socket.broadcastExcluding(JSON.stringify({
93
+ type: 'user_joined',
94
+ data: this._buildUserInfo(socket, sid),
95
+ }));
96
+
97
+ // 본인에게 전체 목록 응답
98
+ return {
99
+ type: 'userlist',
100
+ data: this._buildUserList(socket),
101
+ };
102
+ }
103
+
104
+ /** chat_msg — 본인 제외 전체 전송 */
105
+ async handleMessage(socket, data) {
106
+ const name = this._getName(socket, socket.sessionId);
107
+ socket.broadcastExcluding(JSON.stringify({
108
+ type: 'chat_msg',
109
+ data: { user: name, text: data.text || data, timestamp: Date.now() },
110
+ }));
111
+ }
112
+
113
+ /** broadcast — 공지 */
114
+ async handleBroadcast(socket, data) {
115
+ const name = this._getName(socket, socket.sessionId);
116
+ socket.broadcastExcluding(JSON.stringify({
117
+ type: 'broadcast',
118
+ data: { user: name, text: data.text || data, timestamp: Date.now() },
119
+ }));
120
+ }
121
+
122
+ /** typing — 타이핑 표시 */
123
+ async handleTyping(socket) {
124
+ const name = this._getName(socket, socket.sessionId);
125
+ socket.broadcastExcluding(JSON.stringify({
126
+ type: 'typing',
127
+ data: { user: name },
128
+ }));
129
+ }
130
+
131
+ /** userlist — 목록 요청 */
132
+ async handleUserList(socket) {
133
+ return {
134
+ type: 'userlist',
135
+ data: this._buildUserList(socket),
136
+ };
137
+ }
138
+ }
@@ -0,0 +1,119 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SSR AuthController — 인증 (로그인/회원가입/로그아웃)
5
+ *
6
+ * 세션 기반 인증. 폼 POST → 세션 저장 → 리다이렉트.
7
+ * Flash 메시지로 에러/성공 알림.
8
+ */
9
+ export default class AuthController extends Controller {
10
+ /** @type {import('@fuzionx/framework').RouteHandler} 로그인 페이지 */
11
+ static loginPage;
12
+ /** @type {import('@fuzionx/framework').RouteHandler} 로그인 처리 */
13
+ static login;
14
+ /** @type {import('@fuzionx/framework').RouteHandler} 회원가입 페이지 */
15
+ static registerPage;
16
+ /** @type {import('@fuzionx/framework').RouteHandler} 회원가입 처리 */
17
+ static register;
18
+ /** @type {import('@fuzionx/framework').RouteHandler} 로그아웃 */
19
+ static logout;
20
+ /** @type {import('@fuzionx/framework').RouteHandler} 하트비트 (세션 연장) */
21
+ static heartbeat;
22
+
23
+ /**
24
+ * GET /login — 로그인 페이지 렌더링
25
+ *
26
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
27
+ * @returns {void}
28
+ */
29
+ async loginPage(ctx) {
30
+ ctx.render('login');
31
+ }
32
+
33
+ /**
34
+ * POST /login — 로그인 폼 처리
35
+ *
36
+ * email/password 검증 후 세션에 userId 저장.
37
+ * 실패 시 flash error + /login 리다이렉트.
38
+ *
39
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
40
+ * @returns {void}
41
+ */
42
+ async login(ctx) {
43
+ const { email, password } = ctx.body;
44
+ const user = await this.service('AuthService').login({ email, password });
45
+
46
+ if (!user) {
47
+ ctx.session.flash('error', ctx.t('auth.login_failed'));
48
+ return ctx.redirect('/login');
49
+ }
50
+
51
+ ctx.session.set('userId', user.id);
52
+ return ctx.redirect('/');
53
+ }
54
+
55
+ /**
56
+ * GET /register — 회원가입 페이지 렌더링
57
+ *
58
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
59
+ * @returns {void}
60
+ */
61
+ async registerPage(ctx) {
62
+ ctx.render('register');
63
+ }
64
+
65
+ /**
66
+ * POST /register — 회원가입 폼 처리
67
+ *
68
+ * 비밀번호 확인 후 AuthService.register() 호출.
69
+ * 성공 → /login 리다이렉트, 실패 → flash error.
70
+ *
71
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
72
+ * @returns {void}
73
+ */
74
+ async register(ctx) {
75
+ const { name, email, password, password_confirm } = ctx.body;
76
+
77
+ if (password !== password_confirm) {
78
+ ctx.session.flash('error', ctx.t('auth.password_mismatch'));
79
+ return ctx.redirect('/register');
80
+ }
81
+
82
+ try {
83
+ await this.service('AuthService').register({ name, email, password });
84
+ ctx.session.flash('success', ctx.t('auth.register_success'));
85
+ return ctx.redirect('/login');
86
+ } catch (e) {
87
+ ctx.session.flash('error', ctx.t(e.message) || e.message);
88
+ return ctx.redirect('/register');
89
+ }
90
+ }
91
+
92
+ /**
93
+ * POST /logout — 로그아웃 처리
94
+ *
95
+ * 세션 파기 후 /login 리다이렉트.
96
+ *
97
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
98
+ * @returns {void}
99
+ */
100
+ async logout(ctx) {
101
+ ctx.session.destroy();
102
+ return ctx.redirect('/login');
103
+ }
104
+
105
+ /**
106
+ * GET /api/heartbeat — 세션 존재 확인 (하트비트)
107
+ *
108
+ * 세션에 userId가 없으면 401 반환.
109
+ * 프론트엔드에서 주기적으로 호출하여 세션 갱신.
110
+ *
111
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
112
+ * @returns {void}
113
+ */
114
+ async heartbeat(ctx) {
115
+ const userId = ctx.session.get('userId');
116
+ if (!userId) return ctx.status(401).json({ alive: false });
117
+ return ctx.json({ alive: true, user: { id: userId } });
118
+ }
119
+ }
@@ -0,0 +1,15 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SSR ChatController — WebSocket 채팅 데모 페이지
5
+ */
6
+ export default class ChatController extends Controller {
7
+ static index;
8
+
9
+ /**
10
+ * GET /chat — 채팅 데모 페이지 렌더링
11
+ */
12
+ async index(ctx) {
13
+ ctx.render('chat');
14
+ }
15
+ }
@@ -0,0 +1,15 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SSR FeaturesController — 프레임워크 기능 상세 소개 페이지
5
+ */
6
+ export default class FeaturesController extends Controller {
7
+ static index;
8
+
9
+ /**
10
+ * GET /features — 기능 소개 페이지 렌더링
11
+ */
12
+ async index(ctx) {
13
+ ctx.render('features');
14
+ }
15
+ }
@@ -0,0 +1,21 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SSR HomeController — 프레임워크 소개 랜딩 페이지
5
+ *
6
+ * 인증 불필요 — 공개 페이지.
7
+ */
8
+ export default class HomeController extends Controller {
9
+ /** @type {import('@fuzionx/framework').RouteHandler} 랜딩 페이지 */
10
+ static index;
11
+
12
+ /**
13
+ * GET / — 프레임워크 소개 랜딩 페이지
14
+ *
15
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
16
+ * @returns {void}
17
+ */
18
+ async index(ctx) {
19
+ ctx.render('home');
20
+ }
21
+ }
@@ -0,0 +1,214 @@
1
+ import { Controller, DateUtil } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SSR PostController — 게시판 CRUD + 첨부파일 + 썸네일
5
+ *
6
+ * ## 주요 기능
7
+ * - 게시글 목록 (대표 썸네일 표시)
8
+ * - 게시글 상세 (첨부파일 + 썸네일, 원본 lightbox)
9
+ * - 글 작성/수정 (멀티파트 파일 업로드 + 자동 썸네일 생성)
10
+ * - 글 삭제 (첨부파일 + 썸네일 + Storage 정리)
11
+ * - 개별 첨부파일 삭제 (수정 모드)
12
+ *
13
+ * @example
14
+ * // routes/web.js
15
+ * r.get('/board', PostController.index);
16
+ * r.get('/board/new', PostController.create);
17
+ * r.post('/board', PostController.store);
18
+ * r.get('/board/:id', PostController.show);
19
+ * r.get('/board/:id/edit', PostController.edit);
20
+ * r.post('/board/:id', PostController.update);
21
+ * r.post('/board/:id/delete', PostController.destroy);
22
+ * r.post('/board/attachment/:id/delete', PostController.deleteAttachment);
23
+ */
24
+ export default class PostController extends Controller {
25
+
26
+ /**
27
+ * GET /board — 게시글 목록 (페이지네이션 + 대표 썸네일)
28
+ *
29
+ * 각 게시글의 첫 번째 첨부파일 썸네일을 대표 이미지로 표시.
30
+ * 썸네일이 없는 게시글은 텍스트만 표시.
31
+ *
32
+ * @param {import('@fuzionx/framework').Context} ctx
33
+ */
34
+ async index(ctx) {
35
+ const page = parseInt(ctx.query.page || '1');
36
+ const result = await this.service('PostService').list(page, 10);
37
+
38
+ // 게시글 목록의 대표 썸네일 한 번에 조회 (N+1 방지)
39
+ const postIds = result.data.map(p => p.id);
40
+ const thumbMap = await this.service('PostService').getPostThumbnails(postIds);
41
+
42
+ // 작성자 이름 batch 조회 (N+1 방지)
43
+ const userIds = [...new Set(result.data.map(p => p.user_id).filter(Boolean))];
44
+ const userMap = new Map();
45
+ if (userIds.length) {
46
+ const users = await this.db.User.query().whereIn('id', userIds).get();
47
+ for (const u of users) userMap.set(u.id, u.name);
48
+ }
49
+
50
+ // 각 게시글에 thumbUrl, user_name, 포맷된 날짜 추가
51
+ const posts = result.data.map(p => ({
52
+ ...p,
53
+ thumbUrl: thumbMap.get(p.id) || null,
54
+ user_name: userMap.get(p.user_id) || '-',
55
+ created_at: DateUtil.format(p.created_at, 'YYYY-MM-DD HH:mm'),
56
+ }));
57
+
58
+ ctx.render('board/index', {
59
+ posts,
60
+ page: result.page,
61
+ lastPage: result.lastPage,
62
+ hasMore: result.hasMore,
63
+ total: result.total,
64
+ });
65
+ }
66
+
67
+ /**
68
+ * GET /board/:id — 게시글 상세 + 첨부파일 + 썸네일
69
+ *
70
+ * 첨부파일 목록에서:
71
+ * - 이미지: 썸네일 표시, 클릭 시 원본 이미지 lightbox
72
+ * - 비디오: 비디오 프레임 썸네일 표시, 클릭 시 원본 비디오 재생
73
+ * - 기타: 파일 아이콘 + 다운로드 링크
74
+ *
75
+ * @param {import('@fuzionx/framework').Context} ctx
76
+ */
77
+ async show(ctx) {
78
+ const postService = this.service('PostService');
79
+ const post = await postService.find(ctx.params.id);
80
+ if (!post) return ctx.status(404).render('errors/404');
81
+
82
+ const author = await this.db.User.find(post.user_id);
83
+ const files = await postService.getAttachmentsWithThumbs(post.id);
84
+
85
+ ctx.render('board/show', { post, author, files });
86
+ }
87
+
88
+ /**
89
+ * GET /board/new — 새 글 작성 폼
90
+ *
91
+ * @param {import('@fuzionx/framework').Context} ctx
92
+ */
93
+ async create(ctx) {
94
+ ctx.render('board/form', { post: null, files: [] });
95
+ }
96
+
97
+ /**
98
+ * POST /board — 새 글 저장 + 첨부파일 업로드
99
+ *
100
+ * 파일 업로드 처리:
101
+ * 1. `ctx.files` — Bridge multipart parser가 추출한 파일 배열
102
+ * 2. PostService.create() → Storage 저장 + 썸네일 자동 생성
103
+ * 3. PRG 패턴: /board로 리다이렉트
104
+ *
105
+ * @param {import('@fuzionx/framework').Context} ctx
106
+ */
107
+ async store(ctx) {
108
+ // 업로드 오류 처리 (파일 타입/크기 제한 등)
109
+ if (ctx.uploadError) {
110
+ ctx.session.flash('error', ctx.uploadError);
111
+ return ctx.redirect('/board/new');
112
+ }
113
+ const { title, content } = ctx.body;
114
+ const uploadedFiles = ctx.files || [];
115
+ await this.service('PostService').create(
116
+ { title, content, user_id: ctx.user.id },
117
+ uploadedFiles,
118
+ );
119
+
120
+ ctx.session.flash('success', ctx.t('board.store_success', { default: '게시글이 성공적으로 저장되었습니다.' }));
121
+ return ctx.redirect('/board');
122
+ }
123
+
124
+ /**
125
+ * GET /board/:id/edit — 글 수정 폼 + 기존 첨부파일 관리
126
+ *
127
+ * 기존 첨부파일의 썸네일과 삭제 버튼을 함께 표시.
128
+ * 작성자 본인만 접근 가능.
129
+ *
130
+ * @param {import('@fuzionx/framework').Context} ctx
131
+ */
132
+ async edit(ctx) {
133
+ const postService = this.service('PostService');
134
+ const post = await postService.find(ctx.params.id);
135
+ if (!post || post.user_id !== ctx.user.id) return ctx.status(403).render('errors/404');
136
+
137
+ const files = await postService.getAttachmentsWithThumbs(post.id);
138
+ ctx.render('board/form', { post, files });
139
+ }
140
+
141
+ /**
142
+ * POST /board/:id — 글 수정 + 새 첨부파일 추가
143
+ *
144
+ * 기존 파일은 유지, 새 파일만 추가.
145
+ * 개별 파일 삭제는 deleteAttachment 엔드포인트 사용.
146
+ *
147
+ * @param {import('@fuzionx/framework').Context} ctx
148
+ */
149
+ async update(ctx) {
150
+ // 업로드 오류 처리
151
+ if (ctx.uploadError) {
152
+ ctx.session.flash('error', ctx.uploadError);
153
+ return ctx.redirect(`/board/${ctx.params.id}/edit`);
154
+ }
155
+ const { title, content } = ctx.body;
156
+ const uploadedFiles = ctx.files || [];
157
+ const post = await this.service('PostService').find(ctx.params.id);
158
+ if (!post || post.user_id !== ctx.user.id) return ctx.status(403).render('errors/404');
159
+ await this.service('PostService').update(ctx.params.id, { title, content }, uploadedFiles);
160
+ ctx.session.flash('success', ctx.t('board.update_success', { default: '게시글이 성공적으로 수정되었습니다.' }));
161
+ return ctx.redirect(`/board/${ctx.params.id}`);
162
+ }
163
+
164
+ /**
165
+ * POST /board/:id/delete — 글 삭제 (첨부파일 + 썸네일 + Storage 전부 정리)
166
+ *
167
+ * @param {import('@fuzionx/framework').Context} ctx
168
+ */
169
+ async destroy(ctx) {
170
+ const post = await this.service('PostService').find(ctx.params.id);
171
+ if (!post || post.user_id !== ctx.user.id) return ctx.status(403).render('errors/404');
172
+ await this.service('PostService').remove(ctx.params.id);
173
+ ctx.session.flash('success', ctx.t('board.delete_success', { default: '게시글이 성공적으로 삭제되었습니다.' }));
174
+ return ctx.redirect('/board');
175
+ }
176
+
177
+ /**
178
+ * POST /board/attachment/:id/delete — 개별 첨부파일 삭제
179
+ *
180
+ * 수정 페이지에서 기존 첨부파일 개별 삭제.
181
+ * 원본 파일 + 썸네일 + DB 레코드 모두 정리.
182
+ * 작성자 본인만 삭제 가능.
183
+ *
184
+ * @param {import('@fuzionx/framework').Context} ctx
185
+ */
186
+ async deleteAttachment(ctx) {
187
+ const att = await this.db.Attachment.find(ctx.params.id);
188
+ if (!att) return ctx.status(404).json({ error: 'Not found' });
189
+
190
+ const post = await this.db.Post.find(att.post_id);
191
+ if (!post || post.user_id !== ctx.user.id) return ctx.status(403).json({ error: 'Forbidden' });
192
+
193
+ await this.service('PostService').removeAttachment(att.id);
194
+ return ctx.redirect(`/board/${att.post_id}/edit`);
195
+ }
196
+
197
+ /**
198
+ * GET /api/board/status?ids=1,2,3 — 게시글 상태 JSON 반환
199
+ *
200
+ * @param {import('@fuzionx/framework').Context} ctx
201
+ */
202
+ async status(ctx) {
203
+ const raw = ctx.query.ids || '';
204
+ const ids = raw.split(',').map(Number).filter(Boolean);
205
+ if (!ids.length) return ctx.json({ statuses: {} });
206
+
207
+ const posts = await this.db.Post.query().whereIn('id', ids).get();
208
+ const statuses = {};
209
+ for (const p of posts) {
210
+ statuses[p.id] = p.status || 'published';
211
+ }
212
+ ctx.json({ statuses });
213
+ }
214
+ }
@@ -0,0 +1,48 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SSR UserController — 사용자 프로필 관리
5
+ *
6
+ * 프로필 조회/수정. 비밀번호 변경 시 확인 검증.
7
+ */
8
+ export default class UserController extends Controller {
9
+ /** @type {import('@fuzionx/framework').RouteHandler} 프로필 페이지 */
10
+ static profile;
11
+ /** @type {import('@fuzionx/framework').RouteHandler} 프로필 수정 처리 */
12
+ static updateProfile;
13
+
14
+ /**
15
+ * GET /profile — 프로필 수정 페이지 렌더링
16
+ *
17
+ * 로그인된 사용자 정보를 Tera 템플릿으로 렌더링.
18
+ *
19
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
20
+ * @returns {void}
21
+ */
22
+ async profile(ctx) {
23
+ ctx.render('profile');
24
+ }
25
+
26
+ /**
27
+ * POST /profile — 프로필 수정 처리
28
+ *
29
+ * form body에서 name/email/password 추출.
30
+ * 비밀번호 변경 시 password_confirm 일치 확인.
31
+ * 수정 후 flash success + /profile 리다이렉트.
32
+ *
33
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
34
+ * @returns {void}
35
+ */
36
+ async updateProfile(ctx) {
37
+ const { name, email, password, password_confirm } = ctx.body;
38
+
39
+ if (password && password !== password_confirm) {
40
+ ctx.session.flash('error', ctx.t('auth.password_mismatch'));
41
+ return ctx.redirect('/profile');
42
+ }
43
+
44
+ await this.service('UserService').update(ctx.user.id, { name, email, password });
45
+ ctx.session.flash('success', ctx.t('profile.update_success'));
46
+ return ctx.redirect('/profile');
47
+ }
48
+ }
@@ -0,0 +1,43 @@
1
+ /* ═══════════════════════════════════════════════
2
+ * FuzionX — Toast & Alert UI Styles
3
+ * ═══════════════════════════════════════════════ */
4
+
5
+ /* ── Toast Container ── */
6
+ .fx-toast-container { position:fixed; top:1rem; right:1rem; z-index:10000; display:flex; flex-direction:column; gap:.5rem; max-width:380px; pointer-events:none; }
7
+ .fx-toast { display:flex; align-items:center; gap:.6rem; padding:.75rem 1rem; border-radius:10px; backdrop-filter:blur(12px); border:1px solid rgba(255,255,255,.1); color:#fff; font-size:.88rem; line-height:1.4; cursor:pointer; pointer-events:auto; box-shadow:0 8px 32px rgba(0,0,0,.3); }
8
+ .fx-toast-success { background:rgba(46,204,113,.9); border-color:rgba(46,204,113,.4); }
9
+ .fx-toast-error { background:rgba(231,76,60,.9); border-color:rgba(231,76,60,.4); }
10
+ .fx-toast-warning { background:rgba(241,196,15,.9); border-color:rgba(241,196,15,.4); color:#1a1a2e; }
11
+ .fx-toast-info { background:rgba(102,126,234,.9); border-color:rgba(102,126,234,.4); }
12
+ .fx-toast-icon { font-size:1rem; font-weight:700; flex-shrink:0; width:22px; height:22px; display:flex; align-items:center; justify-content:center; border-radius:50%; background:rgba(255,255,255,.2); }
13
+ .fx-toast-msg { flex:1; }
14
+ .fx-toast-close { background:none; border:none; color:inherit; font-size:1.2rem; cursor:pointer; opacity:.7; padding:0 2px; line-height:1; }
15
+ .fx-toast-close:hover { opacity:1; }
16
+
17
+ @keyframes toastIn { from{opacity:0;transform:translateX(80px) scale(.9)} to{opacity:1;transform:translateX(0) scale(1)} }
18
+ @keyframes toastOut { from{opacity:1;transform:translateX(0) scale(1)} to{opacity:0;transform:translateX(80px) scale(.9)} }
19
+
20
+ /* ── Alert Dialog ── */
21
+ .fx-alert-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:10001; display:flex; align-items:center; justify-content:center; padding:1rem; }
22
+ .fx-alert-dialog { background:rgba(25,25,50,.98); backdrop-filter:blur(20px); border:1px solid rgba(255,255,255,.12); border-radius:16px; padding:2rem; max-width:420px; width:100%; box-shadow:0 24px 64px rgba(0,0,0,.5); }
23
+ .fx-alert-header { display:flex; align-items:center; gap:.75rem; margin-bottom:.75rem; }
24
+ .fx-alert-icon { width:36px; height:36px; display:flex; align-items:center; justify-content:center; border-radius:10px; font-size:1.1rem; flex-shrink:0; }
25
+ .fx-alert-info .fx-alert-icon, .fx-alert-confirm .fx-alert-icon { background:rgba(102,126,234,.15); color:#7b93ff; }
26
+ .fx-alert-warning .fx-alert-icon { background:rgba(241,196,15,.15); color:#f1c40f; }
27
+ .fx-alert-error .fx-alert-icon { background:rgba(231,76,60,.15); color:#e74c3c; }
28
+ .fx-alert-title { font-size:1.15rem; font-weight:700; color:#fff; margin:0; }
29
+ .fx-alert-message { font-size:.9rem; color:rgba(255,255,255,.7); line-height:1.6; margin:0 0 1.5rem; }
30
+ .fx-alert-actions { display:flex; gap:.75rem; justify-content:flex-end; }
31
+ .fx-alert-btn { padding:.6rem 1.5rem; border-radius:10px; font-size:.88rem; font-weight:600; cursor:pointer; border:none; transition:all .2s; font-family:inherit; }
32
+ .fx-alert-ok { background:linear-gradient(135deg,#667eea,#764ba2); color:#fff; }
33
+ .fx-alert-ok:hover { transform:translateY(-1px); box-shadow:0 4px 15px rgba(102,126,234,.3); }
34
+ .fx-alert-cancel { background:rgba(255,255,255,.08); color:rgba(255,255,255,.7); border:1px solid rgba(255,255,255,.1); }
35
+ .fx-alert-cancel:hover { background:rgba(255,255,255,.12); color:#fff; }
36
+
37
+ @keyframes alertIn { from{opacity:0;transform:scale(.95)} to{opacity:1;transform:scale(1)} }
38
+ @keyframes alertOut { from{opacity:1;transform:scale(1)} to{opacity:0;transform:scale(.95)} }
39
+
40
+ /* ── Mobile ── */
41
+ @media(max-width:480px) {
42
+ .fx-toast-container { left:.5rem; right:.5rem; max-width:none; }
43
+ }