@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.
Files changed (87) hide show
  1. package/README.md +29 -2
  2. package/cli/index.js +57 -18
  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,68 @@
1
+ <!--
2
+ Navbar.vue — 메인 네비게이션 바.
3
+
4
+ SSR main.html의 navbar를 Vue 컴포넌트로 완전 복제.
5
+ 인증 여부에 따라 프로필/로그아웃 또는 로그인/회원가입 표시.
6
+ 로케일 전환 버튼 포함. 모바일 토글 지원.
7
+ -->
8
+ <template>
9
+ <nav class="navbar">
10
+ <div class="nav-container">
11
+ <router-link to="/" class="nav-brand">FuzionX</router-link>
12
+ <div :class="['nav-links', { open: mobileOpen }]">
13
+ <router-link to="/" class="nav-link">{{ t('nav.home', 'Home') }}</router-link>
14
+ <router-link to="/features" class="nav-link">{{ t('nav.features', 'Features') }}</router-link>
15
+ <router-link to="/chat" class="nav-link">{{ t('nav.chat', 'Chat Demo') }}</router-link>
16
+ <router-link to="/board" class="nav-link">{{ t('nav.board', '게시판') }}</router-link>
17
+ <template v-if="authStore.isAuthenticated">
18
+ <router-link to="/profile" class="nav-link">{{ t('nav.profile', '프로필') }}</router-link>
19
+ <button class="nav-link btn-link" @click="handleLogout">{{ t('nav.logout', '로그아웃') }}</button>
20
+ </template>
21
+ <template v-else>
22
+ <router-link to="/login" class="nav-link">{{ t('nav.login', '로그인') }}</router-link>
23
+ <router-link to="/register" class="nav-link">{{ t('nav.register', '회원가입') }}</router-link>
24
+ </template>
25
+ <LanguageSwitcher />
26
+ </div>
27
+ <button class="nav-mobile-toggle" @click="mobileOpen = !mobileOpen" aria-label="Menu">
28
+ <span></span><span></span><span></span>
29
+ </button>
30
+ </div>
31
+ </nav>
32
+ <div :class="['nav-backdrop', { open: mobileOpen }]" @click="mobileOpen = false"></div>
33
+ </template>
34
+
35
+ <script setup>
36
+ import { ref, watch } from 'vue';
37
+ import { useRouter, useRoute } from 'vue-router';
38
+ import { useAuthStore } from '../stores/auth.js';
39
+ import { useLocale } from '../composables/useLocale.js';
40
+ import { useApi } from '../composables/useApi.js';
41
+ import LanguageSwitcher from './LanguageSwitcher.vue';
42
+
43
+ const authStore = useAuthStore();
44
+ const { t, locale } = useLocale();
45
+ const router = useRouter();
46
+ const route = useRoute();
47
+ const api = useApi();
48
+
49
+ /** 모바일 메뉴 토글 상태 */
50
+ const mobileOpen = ref(false);
51
+
52
+ /** 라우트 변경 시 모바일 메뉴 자동 닫기 */
53
+ watch(() => route.path, () => {
54
+ mobileOpen.value = false;
55
+ });
56
+
57
+ /** 로그아웃 처리 — 서버 API + Pinia 초기화 */
58
+ async function handleLogout() {
59
+ try {
60
+ await api.post('/api/auth/logout');
61
+ } catch {
62
+ // 무시 — 서버 세션 만료 가능
63
+ }
64
+ authStore.logout();
65
+ mobileOpen.value = false;
66
+ router.push('/login');
67
+ }
68
+ </script>
@@ -0,0 +1,166 @@
1
+ <!--
2
+ Pagination.vue — 범용 페이지네이션 컴포넌트.
3
+
4
+ 번호 페이지 버튼 + 이전/다음 화살표 + ellipsis 지원.
5
+ 개발자가 바로 사용할 수 있는 템플릿 형태.
6
+
7
+ @example
8
+ <Pagination :page="1" :last-page="10" @change="onPageChange" />
9
+ <Pagination :page="5" :last-page="20" :siblings="2" @change="onPageChange" />
10
+ -->
11
+ <template>
12
+ <nav v-if="lastPage > 1" class="pagination" role="navigation" aria-label="Pagination">
13
+ <!-- 이전 -->
14
+ <button
15
+ class="page-btn page-prev"
16
+ :disabled="page <= 1"
17
+ @click="$emit('change', page - 1)"
18
+ :aria-label="t('pagination.prev', '이전')"
19
+ >←</button>
20
+
21
+ <!-- 페이지 번호 -->
22
+ <template v-for="item in pages" :key="item.key">
23
+ <span v-if="item.type === 'ellipsis'" class="page-ellipsis">…</span>
24
+ <button
25
+ v-else
26
+ :class="['page-btn', 'page-num', { active: item.page === page }]"
27
+ @click="$emit('change', item.page)"
28
+ :aria-current="item.page === page ? 'page' : undefined"
29
+ >{{ item.page }}</button>
30
+ </template>
31
+
32
+ <!-- 다음 -->
33
+ <button
34
+ class="page-btn page-next"
35
+ :disabled="page >= lastPage"
36
+ @click="$emit('change', page + 1)"
37
+ :aria-label="t('pagination.next', '다음')"
38
+ >→</button>
39
+
40
+ <!-- 페이지 정보 (모바일) -->
41
+ <span class="page-info-mobile">{{ page }} / {{ lastPage }}</span>
42
+ </nav>
43
+ </template>
44
+
45
+ <script setup>
46
+ import { computed } from 'vue';
47
+ import { useLocale } from '../composables/useLocale.js';
48
+
49
+ const { t } = useLocale();
50
+
51
+ /**
52
+ * @prop {number} page - 현재 페이지 (1-indexed)
53
+ * @prop {number} lastPage - 마지막 페이지
54
+ * @prop {number} siblings - 현재 페이지 좌우 표시할 페이지 수 (기본 1)
55
+ */
56
+ const props = defineProps({
57
+ page: { type: Number, required: true },
58
+ lastPage: { type: Number, required: true },
59
+ siblings: { type: Number, default: 1 },
60
+ });
61
+
62
+ defineEmits(['change']);
63
+
64
+ /**
65
+ * 페이지 번호 목록 생성 (ellipsis 포함).
66
+ * [1] ... [4] [5*] [6] ... [20]
67
+ */
68
+ const pages = computed(() => {
69
+ const { page, lastPage, siblings } = props;
70
+ const items = [];
71
+
72
+ // 항상 첫 페이지
73
+ items.push({ type: 'page', page: 1, key: 'p1' });
74
+
75
+ const rangeStart = Math.max(2, page - siblings);
76
+ const rangeEnd = Math.min(lastPage - 1, page + siblings);
77
+
78
+ // 첫 페이지와 범위 사이에 간격이 있으면 ellipsis
79
+ if (rangeStart > 2) {
80
+ items.push({ type: 'ellipsis', key: 'e1' });
81
+ }
82
+
83
+ // 중간 범위
84
+ for (let i = rangeStart; i <= rangeEnd; i++) {
85
+ items.push({ type: 'page', page: i, key: `p${i}` });
86
+ }
87
+
88
+ // 범위와 마지막 페이지 사이에 간격이 있으면 ellipsis
89
+ if (rangeEnd < lastPage - 1) {
90
+ items.push({ type: 'ellipsis', key: 'e2' });
91
+ }
92
+
93
+ // 항상 마지막 페이지 (1 초과일 때만)
94
+ if (lastPage > 1) {
95
+ items.push({ type: 'page', page: lastPage, key: `p${lastPage}` });
96
+ }
97
+
98
+ return items;
99
+ });
100
+ </script>
101
+
102
+ <style scoped>
103
+ .pagination {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ gap: 4px;
108
+ margin-top: 2rem;
109
+ flex-wrap: wrap;
110
+ }
111
+ .page-btn {
112
+ display: inline-flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ min-width: 36px;
116
+ height: 36px;
117
+ padding: 0 8px;
118
+ border: 1px solid var(--glass-border, rgba(255,255,255,0.1));
119
+ border-radius: 8px;
120
+ background: rgba(255,255,255,0.05);
121
+ color: var(--text-secondary, rgba(255,255,255,0.65));
122
+ font-size: 0.85rem;
123
+ font-weight: 500;
124
+ cursor: pointer;
125
+ transition: all 0.2s;
126
+ font-family: inherit;
127
+ }
128
+ .page-btn:hover:not(:disabled):not(.active) {
129
+ background: rgba(255,255,255,0.12);
130
+ color: #fff;
131
+ border-color: rgba(102,126,234,0.4);
132
+ }
133
+ .page-btn:disabled {
134
+ opacity: 0.35;
135
+ cursor: not-allowed;
136
+ }
137
+ .page-btn.active {
138
+ background: linear-gradient(135deg, #667eea, #764ba2);
139
+ color: #fff;
140
+ border-color: transparent;
141
+ font-weight: 700;
142
+ box-shadow: 0 2px 8px rgba(102,126,234,0.3);
143
+ }
144
+ .page-ellipsis {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ width: 36px;
149
+ height: 36px;
150
+ color: var(--text-muted, rgba(255,255,255,0.35));
151
+ font-size: 0.9rem;
152
+ user-select: none;
153
+ }
154
+ .page-info-mobile {
155
+ display: none;
156
+ }
157
+ @media (max-width: 480px) {
158
+ .page-num, .page-ellipsis { display: none; }
159
+ .page-info-mobile {
160
+ display: inline-flex;
161
+ font-size: 0.85rem;
162
+ color: var(--text-secondary);
163
+ padding: 0 0.5rem;
164
+ }
165
+ }
166
+ </style>
@@ -0,0 +1,135 @@
1
+ <!--
2
+ ToastContainer.vue — 토스트 알림 컨테이너.
3
+
4
+ App.vue에 1회 포함하면 $toast.success() 등으로 자동 표시.
5
+ 우측 상단 스택, 자동 dismiss, 애니메이션 포함.
6
+ -->
7
+ <template>
8
+ <Teleport to="body">
9
+ <div class="fx-toast-container" v-if="toasts.length">
10
+ <TransitionGroup name="toast">
11
+ <div
12
+ v-for="item in toasts"
13
+ :key="item.id"
14
+ :class="['fx-toast', `fx-toast-${item.type}`]"
15
+ @click="dismiss(item.id)"
16
+ >
17
+ <span class="fx-toast-icon">{{ icons[item.type] || icons.info }}</span>
18
+ <span class="fx-toast-msg">{{ item.message }}</span>
19
+ <button class="fx-toast-close" @click.stop="dismiss(item.id)">×</button>
20
+ </div>
21
+ </TransitionGroup>
22
+ </div>
23
+ </Teleport>
24
+ </template>
25
+
26
+ <script setup>
27
+ import { toastState, toast } from '../plugins/toast.js';
28
+ import { computed } from 'vue';
29
+
30
+ const toasts = computed(() => toastState.items);
31
+ const dismiss = toast.dismiss;
32
+
33
+ const icons = {
34
+ success: '✓',
35
+ error: '✕',
36
+ warning: '⚠',
37
+ info: 'ℹ',
38
+ };
39
+ </script>
40
+
41
+ <style>
42
+ /* ── Toast Container ── */
43
+ .fx-toast-container {
44
+ position: fixed;
45
+ top: 1rem;
46
+ right: 1rem;
47
+ z-index: 10000;
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 0.5rem;
51
+ max-width: 380px;
52
+ pointer-events: none;
53
+ }
54
+ .fx-toast {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 0.6rem;
58
+ padding: 0.75rem 1rem;
59
+ border-radius: 10px;
60
+ backdrop-filter: blur(12px);
61
+ border: 1px solid rgba(255,255,255,0.1);
62
+ color: #fff;
63
+ font-size: 0.88rem;
64
+ line-height: 1.4;
65
+ cursor: pointer;
66
+ pointer-events: auto;
67
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
68
+ }
69
+ .fx-toast-success {
70
+ background: rgba(46, 204, 113, 0.9);
71
+ border-color: rgba(46, 204, 113, 0.4);
72
+ }
73
+ .fx-toast-error {
74
+ background: rgba(231, 76, 60, 0.9);
75
+ border-color: rgba(231, 76, 60, 0.4);
76
+ }
77
+ .fx-toast-warning {
78
+ background: rgba(241, 196, 15, 0.9);
79
+ border-color: rgba(241, 196, 15, 0.4);
80
+ color: #1a1a2e;
81
+ }
82
+ .fx-toast-info {
83
+ background: rgba(102, 126, 234, 0.9);
84
+ border-color: rgba(102, 126, 234, 0.4);
85
+ }
86
+ .fx-toast-icon {
87
+ font-size: 1rem;
88
+ font-weight: 700;
89
+ flex-shrink: 0;
90
+ width: 22px;
91
+ height: 22px;
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ border-radius: 50%;
96
+ background: rgba(255,255,255,0.2);
97
+ }
98
+ .fx-toast-msg {
99
+ flex: 1;
100
+ }
101
+ .fx-toast-close {
102
+ background: none;
103
+ border: none;
104
+ color: inherit;
105
+ font-size: 1.2rem;
106
+ cursor: pointer;
107
+ opacity: 0.7;
108
+ padding: 0 2px;
109
+ line-height: 1;
110
+ }
111
+ .fx-toast-close:hover { opacity: 1; }
112
+
113
+ /* Transitions */
114
+ .toast-enter-active { animation: toastIn 0.3s ease-out; }
115
+ .toast-leave-active { animation: toastOut 0.25s ease-in forwards; }
116
+ .toast-move { transition: transform 0.3s ease; }
117
+
118
+ @keyframes toastIn {
119
+ from { opacity: 0; transform: translateX(80px) scale(0.9); }
120
+ to { opacity: 1; transform: translateX(0) scale(1); }
121
+ }
122
+ @keyframes toastOut {
123
+ from { opacity: 1; transform: translateX(0) scale(1); }
124
+ to { opacity: 0; transform: translateX(80px) scale(0.9); }
125
+ }
126
+
127
+ /* Mobile */
128
+ @media (max-width: 480px) {
129
+ .fx-toast-container {
130
+ left: 0.5rem;
131
+ right: 0.5rem;
132
+ max-width: none;
133
+ }
134
+ }
135
+ </style>
@@ -0,0 +1,129 @@
1
+ /**
2
+ * useApi — FuzionX WASM client를 사용한 ASP 호환 API 호출.
3
+ *
4
+ * tester 템플릿의 aspFetch 패턴 적용:
5
+ * - _aspReady Promise로 WASM 초기화 대기
6
+ * - /api 경로는 WASM client 메서드 사용 (자동 ASP 암/복호화)
7
+ * - FormData는 upload() 메서드로 위임
8
+ * - 비-api 경로는 plain fetch
9
+ */
10
+
11
+ let _aspClient = null;
12
+ let _aspReady = null;
13
+
14
+ /**
15
+ * main.js에서 WASM 초기화 후 호출.
16
+ * @param {object} client - FuzionXClient 인스턴스
17
+ */
18
+ export function setAspClient(client) {
19
+ _aspClient = client;
20
+ }
21
+
22
+ /**
23
+ * main.js에서 WASM 로딩 Promise를 설정.
24
+ * @param {Promise} promise - WASM 초기화 Promise
25
+ */
26
+ export function setAspReady(promise) {
27
+ _aspReady = promise;
28
+ }
29
+
30
+ export function useApi() {
31
+ /**
32
+ * aspFetch — tester 템플릿과 동일한 패턴.
33
+ * /api 경로: WASM client 메서드로 ASP 자동 처리
34
+ * 비-api 경로: plain fetch
35
+ * @param {string} url - 요청 URL
36
+ * @param {object} opts - fetch 옵션
37
+ */
38
+ async function apiFetch(url, opts = {}) {
39
+ if (_aspReady) await _aspReady;
40
+
41
+ const method = (opts.method || 'GET').toUpperCase();
42
+
43
+ // /api가 아닌 경로는 plain fetch
44
+ if (!url.startsWith('/api')) {
45
+ return window.fetch(url, opts).then(r => r.json());
46
+ }
47
+
48
+ // FormData (파일 업로드) — WASM upload() 메서드로 위임
49
+ if (opts.body instanceof FormData) {
50
+ return _aspClient.upload(url, opts.body);
51
+ }
52
+
53
+ // JSON body 파싱
54
+ let bodyObj = undefined;
55
+ if (opts.body) {
56
+ bodyObj = typeof opts.body === 'string' ? JSON.parse(opts.body) : opts.body;
57
+ }
58
+
59
+ // WASM client 메서드 사용 — 자동 ASP 암/복호화
60
+ let res;
61
+ switch (method) {
62
+ case 'GET': res = _aspClient.get(url, window.__FX__ENC_ENABLED); break;
63
+ case 'POST': res = _aspClient.post(url, bodyObj, window.__FX__ENC_ENABLED); break;
64
+ case 'PUT': res = _aspClient.put(url, bodyObj, window.__FX__ENC_ENABLED); break;
65
+ case 'PATCH': res = _aspClient.patch(url, bodyObj, window.__FX__ENC_ENABLED); break;
66
+ case 'DELETE': res = _aspClient.delete(url, window.__FX__ENC_ENABLED); break;
67
+ default: res = _aspClient.get(url, window.__FX__ENC_ENABLED); break;
68
+ }
69
+
70
+ if (res.status === 401) {
71
+ localStorage.removeItem('fx_auth');
72
+ window.location.href = '/#/login';
73
+ return;
74
+ }
75
+
76
+ return res;
77
+ }
78
+
79
+ /**
80
+ * XHR 업로드 — 진행률 콜백 지원.
81
+ * @param {string} url - 업로드 URL
82
+ * @param {FormData} formData - 폼 데이터
83
+ * @param {Function} onProgress - 진행률 콜백 (percent, speed)
84
+ * @param {string} [method='POST'] - HTTP 메서드
85
+ * @returns {Promise<{ok: boolean, responseURL: string}>}
86
+ */
87
+ function uploadWithProgress(url, formData, onProgress, method = 'POST') {
88
+ return new Promise((resolve, reject) => {
89
+ const xhr = new XMLHttpRequest();
90
+ const startTime = Date.now();
91
+
92
+ xhr.upload.onprogress = (ev) => {
93
+ if (!ev.lengthComputable || !onProgress) return;
94
+ const pct = Math.round((ev.loaded / ev.total) * 100);
95
+ const elapsed = (Date.now() - startTime) / 1000;
96
+ let speed = '';
97
+ if (elapsed > 0.5) {
98
+ const bps = ev.loaded / elapsed;
99
+ speed = bps > 1048576
100
+ ? (bps / 1048576).toFixed(1) + ' MB/s'
101
+ : (bps / 1024).toFixed(0) + ' KB/s';
102
+ }
103
+ onProgress(pct, speed);
104
+ };
105
+
106
+ xhr.onload = () => {
107
+ if (xhr.status >= 200 && xhr.status < 400) {
108
+ resolve({ ok: true, responseURL: xhr.responseURL });
109
+ } else {
110
+ reject(new Error(`Upload failed: ${xhr.status}`));
111
+ }
112
+ };
113
+
114
+ xhr.onerror = () => reject(new Error('Network error'));
115
+ xhr.open(method, url);
116
+ xhr.send(formData);
117
+ });
118
+ }
119
+
120
+ // 편의 메서드
121
+ const get = (url) => apiFetch(url);
122
+ const post = (url, body) => apiFetch(url, { method: 'POST', body });
123
+ const put = (url, body) => apiFetch(url, { method: 'PUT', body });
124
+ const patch = (url, body) => apiFetch(url, { method: 'PATCH', body });
125
+ const del = (url) => apiFetch(url, { method: 'DELETE' });
126
+ const upload = (url, formData) => apiFetch(url, { method: 'POST', body: formData });
127
+
128
+ return { fetch: apiFetch, get, post, put, patch, del, upload, uploadWithProgress };
129
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * useClipboard — 클립보드 복사 composable.
3
+ *
4
+ * @example
5
+ * const { copy, copied } = useClipboard();
6
+ * copy('텍스트'); // → copied.value = true (2초 후 false)
7
+ */
8
+ import { ref } from 'vue';
9
+
10
+ export function useClipboard() {
11
+ const copied = ref(false);
12
+ let timer = null;
13
+
14
+ /**
15
+ * 텍스트를 클립보드에 복사.
16
+ * @param {string} text
17
+ * @param {number} [resetMs=2000] - copied 상태 리셋 시간
18
+ * @returns {Promise<boolean>}
19
+ */
20
+ async function copy(text, resetMs = 2000) {
21
+ try {
22
+ await navigator.clipboard.writeText(text);
23
+ copied.value = true;
24
+ if (timer) clearTimeout(timer);
25
+ timer = setTimeout(() => { copied.value = false; }, resetMs);
26
+ return true;
27
+ } catch {
28
+ // 폴백 — textarea 이용
29
+ const ta = document.createElement('textarea');
30
+ ta.value = text;
31
+ ta.style.cssText = 'position:fixed;left:-9999px';
32
+ document.body.appendChild(ta);
33
+ ta.select();
34
+ document.execCommand('copy');
35
+ document.body.removeChild(ta);
36
+ copied.value = true;
37
+ if (timer) clearTimeout(timer);
38
+ timer = setTimeout(() => { copied.value = false; }, resetMs);
39
+ return true;
40
+ }
41
+ }
42
+
43
+ return { copy, copied };
44
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * useDate — 날짜 포맷팅/상대시간 composable.
3
+ *
4
+ * 자주 쓰는 날짜 변환을 간결하게 사용.
5
+ * @example
6
+ * const { format, ago, isToday } = useDate();
7
+ * format(post.created_at); // '2026-03-27 12:05'
8
+ * ago(post.created_at); // '3시간 전'
9
+ * isToday(post.created_at); // true
10
+ */
11
+ export function useDate() {
12
+ const pad = (n) => String(n).padStart(2, '0');
13
+
14
+ /**
15
+ * ISO → 포맷 문자열.
16
+ * @param {string|Date} date
17
+ * @param {string} [fmt='YYYY-MM-DD HH:mm'] - 지원: YYYY, MM, DD, HH, mm, ss
18
+ * @returns {string}
19
+ */
20
+ function format(date, fmt = 'YYYY-MM-DD HH:mm') {
21
+ if (!date) return '-';
22
+ const d = date instanceof Date ? date : new Date(date);
23
+ if (isNaN(d.getTime())) return '-';
24
+ const tokens = {
25
+ YYYY: d.getFullYear(),
26
+ MM: pad(d.getMonth() + 1),
27
+ DD: pad(d.getDate()),
28
+ HH: pad(d.getHours()),
29
+ mm: pad(d.getMinutes()),
30
+ ss: pad(d.getSeconds()),
31
+ };
32
+ let result = fmt;
33
+ for (const [token, value] of Object.entries(tokens)) {
34
+ result = result.replace(token, value);
35
+ }
36
+ return result;
37
+ }
38
+
39
+ /**
40
+ * 상대 시간 (N분 전, N시간 전, ...)
41
+ * @param {string|Date} date
42
+ * @param {string} [locale='ko']
43
+ * @returns {string}
44
+ */
45
+ function ago(date, locale = 'ko') {
46
+ if (!date) return '-';
47
+ const d = date instanceof Date ? date : new Date(date);
48
+ const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
49
+
50
+ const units = locale === 'ko'
51
+ ? { just: '방금 전', sec: '초 전', min: '분 전', hour: '시간 전', day: '일 전', month: '개월 전', year: '년 전' }
52
+ : { just: 'just now', sec: 's ago', min: 'm ago', hour: 'h ago', day: 'd ago', month: 'mo ago', year: 'y ago' };
53
+
54
+ if (diffSec < 10) return units.just;
55
+ if (diffSec < 60) return `${diffSec}${units.sec}`;
56
+ if (diffSec < 3600) return `${Math.floor(diffSec / 60)}${units.min}`;
57
+ if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}${units.hour}`;
58
+ if (diffSec < 2592000) return `${Math.floor(diffSec / 86400)}${units.day}`;
59
+ if (diffSec < 31536000) return `${Math.floor(diffSec / 2592000)}${units.month}`;
60
+ return `${Math.floor(diffSec / 31536000)}${units.year}`;
61
+ }
62
+
63
+ /** 오늘인지 확인 */
64
+ function isToday(date) {
65
+ const d = date instanceof Date ? date : new Date(date);
66
+ const now = new Date();
67
+ return d.getFullYear() === now.getFullYear()
68
+ && d.getMonth() === now.getMonth()
69
+ && d.getDate() === now.getDate();
70
+ }
71
+
72
+ return { format, ago, isToday };
73
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * useDebounce — 디바운스 유틸리티 composable.
3
+ *
4
+ * 검색 입력, 창 리사이즈 등 빈번한 이벤트 제어.
5
+ * @example
6
+ * const { debounce } = useDebounce();
7
+ * const search = debounce((q) => api.get(`/api/search?q=${q}`), 300);
8
+ */
9
+ import { onUnmounted } from 'vue';
10
+
11
+ export function useDebounce() {
12
+ const timers = new Set();
13
+
14
+ /**
15
+ * 디바운스 함수 생성.
16
+ * @param {Function} fn - 지연 실행할 함수
17
+ * @param {number} [ms=300] - 디바운스 지연 시간 (ms)
18
+ * @returns {Function}
19
+ */
20
+ function debounce(fn, ms = 300) {
21
+ let timer = null;
22
+ const wrapped = (...args) => {
23
+ if (timer) clearTimeout(timer);
24
+ timer = setTimeout(() => fn(...args), ms);
25
+ timers.add(timer);
26
+ };
27
+ wrapped.cancel = () => {
28
+ if (timer) {
29
+ clearTimeout(timer);
30
+ timers.delete(timer);
31
+ }
32
+ };
33
+ return wrapped;
34
+ }
35
+
36
+ /**
37
+ * 쓰로틀 함수 생성.
38
+ * @param {Function} fn - 실행할 함수
39
+ * @param {number} [ms=300] - 최소 실행 간격 (ms)
40
+ * @returns {Function}
41
+ */
42
+ function throttle(fn, ms = 300) {
43
+ let last = 0;
44
+ return (...args) => {
45
+ const now = Date.now();
46
+ if (now - last >= ms) {
47
+ last = now;
48
+ fn(...args);
49
+ }
50
+ };
51
+ }
52
+
53
+ onUnmounted(() => {
54
+ for (const t of timers) clearTimeout(t);
55
+ timers.clear();
56
+ });
57
+
58
+ return { debounce, throttle };
59
+ }