@fuzionx/framework 0.1.28 → 0.1.29

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 (38) hide show
  1. package/cli/index.js +66 -53
  2. package/cli/templates/app/database/models/User.js +9 -0
  3. package/cli/templates/app/fuzionx.yaml.tpl +3 -3
  4. package/cli/templates/app/locales/en.json +52 -0
  5. package/cli/templates/app/locales/ko.json +52 -0
  6. package/cli/templates/app/package.json.tpl +2 -1
  7. package/cli/templates/app/shared/events/userEvents.js +10 -0
  8. package/cli/templates/app/shared/jobs/CleanupJob.js +18 -0
  9. package/cli/templates/app/shared/jobs/EmailTask.js +17 -0
  10. package/cli/templates/app/shared/jobs/VideoPreviewTask.js +47 -0
  11. package/cli/templates/app/shared/workers/heavy.js +18 -0
  12. package/cli/templates/app/tester/controllers/FileController.js +288 -0
  13. package/cli/templates/app/tester/controllers/HomeController.js +36 -0
  14. package/cli/templates/app/tester/controllers/UserController.js +43 -0
  15. package/cli/templates/app/tester/middleware/RequestLogger.js +13 -0
  16. package/cli/templates/app/tester/routes/api.js +397 -0
  17. package/cli/templates/app/tester/routes/web.js +8 -0
  18. package/cli/templates/app/tester/services/UserService.js +52 -0
  19. package/cli/templates/app/tester/views/default/errors/404.html +15 -0
  20. package/cli/templates/app/tester/views/default/errors/500.html +14 -0
  21. package/cli/templates/app/tester/views/default/layouts/main.html +82 -0
  22. package/cli/templates/app/tester/views/default/pages/home.html +56 -0
  23. package/cli/templates/app/tester/views/default/pages/i18n.html +104 -0
  24. package/cli/templates/app/tester/views/default/pages/upload.html +149 -0
  25. package/cli/templates/app/tester/views/default/pages/websocket.html +239 -0
  26. package/cli/templates/app/tester/views/default/partials/footer.html +8 -0
  27. package/cli/templates/app/tester/views/default/partials/header.html +20 -0
  28. package/cli/templates/app/tester/ws/ChatHandler.js +98 -0
  29. package/lib/core/Application.js +1 -1
  30. package/lib/helpers/Logger.js +6 -6
  31. package/package.json +2 -2
  32. /package/cli/templates/app/{controllers → fuzionx/controllers}/HomeController.js +0 -0
  33. /package/cli/templates/app/{routes → fuzionx/routes}/api.js.tpl +0 -0
  34. /package/cli/templates/app/{routes → fuzionx/routes}/web.js.tpl +0 -0
  35. /package/cli/templates/app/{views → fuzionx/views}/default/errors/404.html +0 -0
  36. /package/cli/templates/app/{views → fuzionx/views}/default/errors/500.html +0 -0
  37. /package/cli/templates/app/{views → fuzionx/views}/default/layouts/main.html +0 -0
  38. /package/cli/templates/app/{views → fuzionx/views}/default/pages/home.html +0 -0
@@ -0,0 +1,104 @@
1
+ {% extends "layouts/main.html" %}
2
+
3
+ {% block title %}i18n — {{ config.app.name | default(value='FuzionX') }}{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .demo-card { background: var(--bs-tertiary-bg); }
8
+ </style>
9
+ {% endblock %}
10
+
11
+ {% block content %}
12
+ <div class="container py-3">
13
+ <div class="card mb-3">
14
+ <div class="card-header d-flex justify-content-between align-items-center">
15
+ <span><i class="bi bi-translate"></i> 언어 전환</span>
16
+ <div class="btn-group" id="localeSwitch">
17
+ <button class="btn btn-info btn-sm active" data-locale="ko" onclick="switchLocale('ko')">🇰🇷 한국어</button>
18
+ <button class="btn btn-outline-secondary btn-sm" data-locale="en" onclick="switchLocale('en')">🇺🇸 English</button>
19
+ </div>
20
+ </div>
21
+ <div class="card-body py-2">
22
+ <div class="row small">
23
+ <div class="col">현재: <span class="text-info fw-bold" id="currentLocale">ko</span></div>
24
+ <div class="col">사용 가능: <span id="availableLocales">—</span></div>
25
+ <div class="col">기본: <span id="defaultLocale">—</span> / 폴백: <span id="fallbackLocale">—</span></div>
26
+ <div class="col">소스: <span class="badge bg-info" id="i18nSource">—</span></div>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ <div class="card mb-3">
31
+ <div class="card-header"><i class="bi bi-key"></i> 실시간 번역 테스트</div>
32
+ <div class="card-body">
33
+ <div class="row g-2 mb-2">
34
+ <div class="col-md-5"><input type="text" class="form-control form-control-sm" id="translateKey" value="common.greeting" placeholder="키"></div>
35
+ <div class="col-md-3"><input type="text" class="form-control form-control-sm" id="translateName" value="FuzionX" placeholder="변수 {name}"></div>
36
+ <div class="col-md-2"><button class="btn btn-info btn-sm w-100" onclick="doTranslate()"><i class="bi bi-search"></i> 번역</button></div>
37
+ </div>
38
+ <div class="d-flex gap-1 flex-wrap mb-2">
39
+ <button class="btn btn-outline-secondary btn-sm" onclick="qk('app.welcome')">app.welcome</button>
40
+ <button class="btn btn-outline-secondary btn-sm" onclick="qk('common.greeting')">common.greeting</button>
41
+ <button class="btn btn-outline-secondary btn-sm" onclick="qk('user.created')">user.created</button>
42
+ <button class="btn btn-outline-secondary btn-sm" onclick="qk('auth.login_required')">auth.login_required</button>
43
+ <button class="btn btn-outline-secondary btn-sm" onclick="qk('upload.drag_drop')">upload.drag_drop</button>
44
+ <button class="btn btn-outline-secondary btn-sm" onclick="qk('common.items_count')">common.items_count</button>
45
+ <button class="btn btn-outline-danger btn-sm" onclick="qk('nonexistent.key')">nonexistent ❌</button>
46
+ </div>
47
+ <pre class="bg-black text-success rounded p-2" id="translateResult">키를 입력하고 번역을 클릭하세요</pre>
48
+ </div>
49
+ </div>
50
+ <div class="row g-3">
51
+ <div class="col-lg-7">
52
+ <div class="card h-100">
53
+ <div class="card-header d-flex justify-content-between">
54
+ <span><i class="bi bi-table"></i> 전체 키 비교 (ko vs en)</span>
55
+ <button class="btn btn-outline-info btn-sm" onclick="loadComparison()"><i class="bi bi-arrow-clockwise"></i> 로드</button>
56
+ </div>
57
+ <div class="card-body p-0" style="max-height:400px;overflow-y:auto">
58
+ <table class="table table-sm table-striped mb-0 small">
59
+ <thead class="sticky-top"><tr><th>Key</th><th>🇰🇷</th><th>🇺🇸</th></tr></thead>
60
+ <tbody id="comparisonBody"><tr><td colspan="3" class="text-muted">로드 버튼 클릭</td></tr></tbody>
61
+ </table>
62
+ </div>
63
+ </div>
64
+ </div>
65
+ <div class="col-lg-5">
66
+ <div class="card h-100">
67
+ <div class="card-header"><i class="bi bi-brush"></i> UI 적용 데모</div>
68
+ <div class="card-body">
69
+ <p class="text-muted small">언어를 전환하면 자동 변경됩니다</p>
70
+ <div class="demo-card rounded p-3 mb-2">
71
+ <h5 id="demo-welcome">—</h5>
72
+ <p class="text-muted" id="demo-desc">—</p>
73
+ <div class="btn-group"><button class="btn btn-info btn-sm" id="demo-save">—</button><button class="btn btn-outline-secondary btn-sm" id="demo-cancel">—</button></div>
74
+ <p class="mt-2 mb-1" id="demo-greeting">—</p>
75
+ <p class="mb-0 text-muted small" id="demo-items">—</p>
76
+ </div>
77
+ <h6 class="mt-3"><i class="bi bi-code-slash"></i> SSR 렌더링 (Bridge)</h6>
78
+ <input type="text" class="form-control form-control-sm mb-2" id="ssrTemplate" value="&#123;&#123;title&#125;&#125; — &#123;&#123;desc&#125;&#125;">
79
+ <div class="row g-2 mb-2">
80
+ <div class="col"><input type="text" class="form-control form-control-sm" id="ssrTitle" value="FuzionX" placeholder="title"></div>
81
+ <div class="col"><input type="text" class="form-control form-control-sm" id="ssrDesc" value="Framework" placeholder="desc"></div>
82
+ </div>
83
+ <button class="btn btn-info btn-sm" onclick="doSSR()"><i class="bi bi-play-fill"></i> 렌더링</button>
84
+ <pre class="bg-black text-success rounded p-2 mt-2" id="ssrResult">—</pre>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ {% endblock %}
91
+
92
+ {% block scripts %}
93
+ <script>
94
+ let currentLocale='ko',allMessages={ko:{},en:{}};
95
+ async function init(){const r=await aspFetch('/api/lang/locales');const d=r instanceof Response?await r.json():r;document.getElementById('availableLocales').textContent=(d.locales||[]).join(', ')||'ko, en';document.getElementById('defaultLocale').textContent=d.default;document.getElementById('fallbackLocale').textContent=d.fallback;document.getElementById('i18nSource').textContent=d.source;for(const l of['ko','en']){const r2=await aspFetch(`/api/lang/all?locale=${l}`);const d2=r2 instanceof Response?await r2.json():r2;allMessages[l]=d2.messages}updateDemo()}
96
+ function switchLocale(l){currentLocale=l;document.getElementById('currentLocale').textContent=l;document.querySelectorAll('#localeSwitch button').forEach(b=>{b.className=b.dataset.locale===l?'btn btn-info btn-sm active':'btn btn-outline-secondary btn-sm'});updateDemo()}
97
+ function updateDemo(){const t=(k,v)=>{let val=allMessages[currentLocale]?.[k]||k;if(v)Object.entries(v).forEach(([a,b])=>{val=val.replace(`{${a}}`,b)});return val};document.getElementById('demo-welcome').textContent=t('app.welcome');document.getElementById('demo-desc').textContent=t('app.description');document.getElementById('demo-save').textContent=t('common.save');document.getElementById('demo-cancel').textContent=t('common.cancel');document.getElementById('demo-greeting').textContent=t('common.greeting',{name:'Admin'});document.getElementById('demo-items').textContent=t('common.items_count',{count:'42'})}
98
+ async function doTranslate(){const key=document.getElementById('translateKey').value,name=document.getElementById('translateName').value;if(!key)return;const p=new URLSearchParams({locale:currentLocale,key});if(name)p.append('name',name);const r=await aspFetch(`/api/lang/translate?${p}`);const d=r instanceof Response?await r.json():r;const p2=new URLSearchParams({locale:currentLocale==='ko'?'en':'ko',key});if(name)p2.append('name',name);const r2=await aspFetch(`/api/lang/translate?${p2}`);const d2=r2 instanceof Response?await r2.json():r2;document.getElementById('translateResult').textContent=`${d.locale}: ${d.value}\n${d2.locale}: ${d2.value}\n\n${JSON.stringify({current:d,other:d2},null,2)}`}
99
+ function qk(k){document.getElementById('translateKey').value=k;doTranslate()}
100
+ async function loadComparison(){const body=document.getElementById('comparisonBody');const keys=new Set([...Object.keys(allMessages.ko||{}),...Object.keys(allMessages.en||{})]);body.innerHTML=[...keys].sort().map(k=>{const ko=allMessages.ko?.[k]||'<span class="text-danger">❌</span>';const en=allMessages.en?.[k]||'<span class="text-danger">❌</span>';return`<tr><td><code>${k}</code></td><td>${ko}</td><td>${en}</td></tr>`}).join('')}
101
+ async function doSSR(){const template=document.getElementById('ssrTemplate').value,context={title:document.getElementById('ssrTitle').value,desc:document.getElementById('ssrDesc').value};const r=await aspFetch('/api/lang/render',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({template,context,locale:currentLocale})});const d=r instanceof Response?await r.json():r;document.getElementById('ssrResult').textContent=JSON.stringify(d,null,2)}
102
+ init();
103
+ </script>
104
+ {% endblock %}
@@ -0,0 +1,149 @@
1
+ {% extends "layouts/main.html" %}
2
+
3
+ {% block title %}Upload & Media — {{ config.app.name | default(value='FuzionX') }}{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .drop-zone { border: 2px dashed #6c757d; border-radius: 8px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.2s; }
8
+ .drop-zone:hover, .drop-zone.drag-over { border-color: #0dcaf0; background: rgba(13,202,240,0.05); }
9
+ .drop-zone input { display: none; }
10
+ .preview img { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; }
11
+ .sprite-preview { max-width: 100%; border-radius: 8px; margin-top: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
12
+ </style>
13
+ {% endblock %}
14
+
15
+ {% block content %}
16
+ <div class="container py-3">
17
+ <div class="card mb-3">
18
+ <div class="card-header"><i class="bi bi-cloud-upload"></i> 파일 업로드</div>
19
+ <div class="card-body">
20
+ <div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
21
+ <i class="bi bi-file-earmark-arrow-up" style="font-size:32px"></i>
22
+ <p class="mt-2 mb-1">파일을 드래그하거나 클릭</p>
23
+ <small class="text-muted">이미지/비디오 (최대 50MB)</small>
24
+ <input type="file" id="fileInput" accept="image/*,video/*" multiple>
25
+ </div>
26
+ <div class="d-flex gap-2 mt-2 flex-wrap" id="preview"></div>
27
+ <div class="btn-group mt-2">
28
+ <button class="btn btn-info btn-sm" onclick="uploadSingle()"><i class="bi bi-upload"></i> 단일 업로드</button>
29
+ <button class="btn btn-outline-info btn-sm" onclick="uploadMultiple()"><i class="bi bi-stack"></i> 다중 업로드</button>
30
+ <button class="btn btn-outline-secondary btn-sm" onclick="clearFiles()"><i class="bi bi-x-circle"></i> 초기화</button>
31
+ </div>
32
+ <pre class="bg-black text-success rounded p-2 mt-2" id="uploadResult">결과 대기</pre>
33
+ </div>
34
+ </div>
35
+ <div class="row g-3">
36
+ <div class="col-md-6">
37
+ <div class="card h-100">
38
+ <div class="card-header"><i class="bi bi-aspect-ratio"></i> 이미지 리사이즈</div>
39
+ <div class="card-body">
40
+ <div class="row g-2 mb-2">
41
+ <div class="col"><input type="number" class="form-control form-control-sm" id="resizeW" value="200" placeholder="Width"></div>
42
+ <div class="col"><input type="number" class="form-control form-control-sm" id="resizeH" value="200" placeholder="Height"></div>
43
+ <div class="col"><select class="form-select form-select-sm" id="resizeFmt"><option>webp</option><option>jpeg</option><option>png</option></select></div>
44
+ <div class="col"><input type="number" class="form-control form-control-sm" id="resizeQ" value="80" placeholder="Quality"></div>
45
+ </div>
46
+ <div class="btn-group">
47
+ <button class="btn btn-info btn-sm" onclick="doResize()"><i class="bi bi-arrows-angle-contract"></i> 리사이즈</button>
48
+ <button class="btn btn-outline-info btn-sm" onclick="doResizeMultiple()">다중 (L/M/S)</button>
49
+ </div>
50
+ <pre class="bg-black text-success rounded p-2 mt-2" id="resizeResult">—</pre>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ <div class="col-md-6">
55
+ <div class="card h-100">
56
+ <div class="card-header"><i class="bi bi-arrow-repeat"></i> 변환 & 정보</div>
57
+ <div class="card-body">
58
+ <div class="btn-group mb-2">
59
+ <button class="btn btn-info btn-sm" onclick="doToWebp()"><i class="bi bi-filetype-png"></i> WebP 변환</button>
60
+ <button class="btn btn-outline-info btn-sm" onclick="doImageInfo()"><i class="bi bi-info-circle"></i> 이미지 정보</button>
61
+ <button class="btn btn-outline-info btn-sm" onclick="doVideoThumb()"><i class="bi bi-film"></i> 비디오 썸네일</button>
62
+ </div>
63
+ <pre class="bg-black text-success rounded p-2 mt-2" id="convertResult">—</pre>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ <div class="row g-3 mt-0">
69
+ <div class="col-12">
70
+ <div class="card">
71
+ <div class="card-header"><i class="bi bi-camera-reels"></i> 비디오 미리보기 (다중 썸네일 + 스프라이트 시트)</div>
72
+ <div class="card-body">
73
+ <div class="row g-2 mb-2">
74
+ <div class="col-auto"><label class="form-label small mb-0">간격(초)</label><input type="number" class="form-control form-control-sm" id="pvInterval" value="5" style="width:80px"></div>
75
+ <div class="col-auto"><label class="form-label small mb-0">폭(px)</label><input type="number" class="form-control form-control-sm" id="pvWidth" value="320" style="width:80px"></div>
76
+ <div class="col-auto"><label class="form-label small mb-0">그리드 열</label><input type="number" class="form-control form-control-sm" id="pvCols" value="10" style="width:80px"></div>
77
+ <div class="col-auto d-flex align-items-end">
78
+ <button class="btn btn-info btn-sm" onclick="doVideoPreview()"><i class="bi bi-grid-3x3"></i> 미리보기 생성</button>
79
+ </div>
80
+ </div>
81
+ <pre class="bg-black text-success rounded p-2" id="previewResult">비디오 파일을 선택 후 "미리보기 생성" 클릭</pre>
82
+ <div id="spriteSheetContainer"></div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ <div class="row g-3 mt-0">
88
+ <div class="col-md-6">
89
+ <div class="card h-100">
90
+ <div class="card-header"><i class="bi bi-shield-lock"></i> 암호화 / 복호화</div>
91
+ <div class="card-body">
92
+ <input type="text" class="form-control form-control-sm mb-2" id="cryptoText" placeholder="암호화할 텍스트">
93
+ <input type="text" class="form-control form-control-sm mb-2" id="cryptoKey" placeholder="키 (기본: test-secret-key-32bytes!)">
94
+ <div class="btn-group"><button class="btn btn-info btn-sm" onclick="doEncrypt()"><i class="bi bi-lock"></i> 암호화</button><button class="btn btn-outline-info btn-sm" onclick="doDecrypt()"><i class="bi bi-unlock"></i> 복호화</button></div>
95
+ <pre class="bg-black text-success rounded p-2 mt-2" id="cryptoResult">—</pre>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ <div class="col-md-6">
100
+ <div class="card h-100">
101
+ <div class="card-header"><i class="bi bi-key"></i> 패스워드 해싱</div>
102
+ <div class="card-body">
103
+ <input type="text" class="form-control form-control-sm mb-2" id="hashPassword" placeholder="패스워드 입력">
104
+ <div class="btn-group"><button class="btn btn-info btn-sm" onclick="doHash('bcrypt')"><i class="bi bi-hash"></i> bcrypt</button><button class="btn btn-outline-info btn-sm" onclick="doHash('argon2')">argon2</button></div>
105
+ <pre class="bg-black text-success rounded p-2 mt-2" id="hashResult">—</pre>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ <div class="card mt-3">
111
+ <div class="card-header d-flex justify-content-between align-items-center">
112
+ <span><i class="bi bi-folder2-open"></i> Storage 파일 목록</span>
113
+ <div class="d-flex gap-2">
114
+ <select class="form-select form-select-sm" style="width:auto" id="listDir"><option>uploads</option><option>resized</option><option>converted</option><option>thumbnails</option><option>previews</option></select>
115
+ <button class="btn btn-outline-info btn-sm" onclick="loadFileList()"><i class="bi bi-arrow-clockwise"></i></button>
116
+ </div>
117
+ </div>
118
+ <ul class="list-group list-group-flush" id="fileList"><li class="list-group-item small">조회 버튼 클릭</li></ul>
119
+ </div>
120
+ </div>
121
+ {% endblock %}
122
+
123
+ {% block scripts %}
124
+ <script>
125
+ let selectedFiles=[];
126
+ const dz=document.getElementById('dropZone');
127
+ dz.addEventListener('dragover',e=>{e.preventDefault();dz.classList.add('drag-over')});
128
+ dz.addEventListener('dragleave',()=>dz.classList.remove('drag-over'));
129
+ dz.addEventListener('drop',e=>{e.preventDefault();dz.classList.remove('drag-over');handleFiles(e.dataTransfer.files)});
130
+ document.getElementById('fileInput').addEventListener('change',e=>handleFiles(e.target.files));
131
+ function handleFiles(files){selectedFiles=Array.from(files);const p=document.getElementById('preview');p.innerHTML='';selectedFiles.forEach(f=>{const d=document.createElement('div');d.className='text-center';if(f.type.startsWith('image/')){const img=document.createElement('img');img.src=URL.createObjectURL(f);d.appendChild(img)}d.innerHTML+=`<div class="small text-muted">${f.name}<br>${(f.size/1024).toFixed(1)}KB</div>`;p.appendChild(d)})}
132
+ function clearFiles(){selectedFiles=[];document.getElementById('preview').innerHTML='';document.getElementById('fileInput').value=''}
133
+ function getFormData(){const fd=new FormData();selectedFiles.forEach(f=>fd.append('file',f));return fd}
134
+ async function apiPost(url,fd,rid){const el=document.getElementById(rid);el.textContent='처리중...';try{const r=await aspFetch(url,{method:'POST',body:fd});if(r instanceof Response){const d=await r.json();el.textContent=`HTTP ${r.status}\n${JSON.stringify(d,null,2)}`}else{el.textContent=`HTTP 200\n${JSON.stringify(r,null,2)}`}}catch(e){el.textContent=e.message}}
135
+ async function apiPostJson(url,body,rid){const el=document.getElementById(rid);el.textContent='처리중...';try{const r=await aspFetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(r instanceof Response){const d=await r.json();el.textContent=`HTTP ${r.status}\n${JSON.stringify(d,null,2)}`}else{el.textContent=`HTTP 200\n${JSON.stringify(r,null,2)}`}}catch(e){el.textContent=e.message}}
136
+ function uploadSingle(){if(!selectedFiles.length)return alert('파일 선택');const fd=new FormData();fd.append('file',selectedFiles[0]);apiPost('/api/files/upload',fd,'uploadResult')}
137
+ function uploadMultiple(){if(!selectedFiles.length)return alert('파일 선택');apiPost('/api/files/upload-multiple',getFormData(),'uploadResult')}
138
+ function doResize(){if(!selectedFiles.length)return alert('이미지 선택');const fd=new FormData();fd.append('file',selectedFiles[0]);apiPost(`/api/media/resize?width=${document.getElementById('resizeW').value}&height=${document.getElementById('resizeH').value}&format=${document.getElementById('resizeFmt').value}&quality=${document.getElementById('resizeQ').value}`,fd,'resizeResult')}
139
+ function doResizeMultiple(){if(!selectedFiles.length)return alert('이미지 선택');const fd=new FormData();fd.append('file',selectedFiles[0]);apiPost('/api/media/resize-multiple',fd,'resizeResult')}
140
+ function doToWebp(){if(!selectedFiles.length)return alert('이미지 선택');const fd=new FormData();fd.append('file',selectedFiles[0]);apiPost('/api/media/to-webp',fd,'convertResult')}
141
+ function doImageInfo(){if(!selectedFiles.length)return alert('이미지 선택');const fd=new FormData();fd.append('file',selectedFiles[0]);apiPost('/api/media/image-info',fd,'convertResult')}
142
+ function doVideoThumb(){if(!selectedFiles.length)return alert('비디오 선택');const fd=new FormData();fd.append('file',selectedFiles[0]);apiPost('/api/media/video-thumbnail',fd,'convertResult')}
143
+ async function doVideoPreview(){if(!selectedFiles.length)return alert('비디오 선택');const fd=new FormData();fd.append('file',selectedFiles[0]);const intv=document.getElementById('pvInterval').value;const w=document.getElementById('pvWidth').value;const cols=document.getElementById('pvCols').value;const el=document.getElementById('previewResult');const sc=document.getElementById('spriteSheetContainer');el.textContent='처리중... (대용량 비디오는 시간이 소요됩니다)';sc.innerHTML='';try{const r=await aspFetch(`/api/media/video-preview?interval=${intv}&width=${w}&cols=${cols}`,{method:'POST',body:fd});const d=r instanceof Response?await r.json():r;el.textContent=`HTTP 200\n${JSON.stringify(d,null,2)}`;if(d.spriteSheet&&d.spriteSheet.path){sc.innerHTML=`<img class="sprite-preview" src="/storage/previews/${d.spriteSheet.path.split('/previews/').pop()}" alt="Sprite Sheet" onerror="this.style.display='none'">`}}catch(e){el.textContent=e.message}}
144
+ function doEncrypt(){const t=document.getElementById('cryptoText').value,k=document.getElementById('cryptoKey').value;if(!t)return alert('텍스트 입력');apiPostJson('/api/utils/encrypt',{text:t,key:k||undefined},'cryptoResult')}
145
+ function doDecrypt(){try{const prev=JSON.parse(document.getElementById('cryptoResult').textContent.split('\n').slice(1).join('\n'));apiPostJson('/api/utils/decrypt',{encrypted:prev.encrypted,key:prev.key},'cryptoResult')}catch{alert('먼저 암호화 실행')}}
146
+ function doHash(algo){const pw=document.getElementById('hashPassword').value;if(!pw)return alert('패스워드 입력');apiPostJson('/api/utils/hash',{password:pw,algorithm:algo},'hashResult')}
147
+ async function loadFileList(){const dir=document.getElementById('listDir').value,el=document.getElementById('fileList');el.innerHTML='<li class="list-group-item small">로딩중...</li>';try{const r=await aspFetch(`/api/files/list?dir=${dir}`);const d=r instanceof Response?await r.json():r;el.innerHTML=d.files.length?d.files.map(f=>`<li class="list-group-item d-flex justify-content-between small"><span>${f.name}</span><span class="text-muted">${(f.size/1024).toFixed(1)}KB</span></li>`).join(''):'<li class="list-group-item small text-muted">파일 없음</li>'}catch(e){el.innerHTML=`<li class="list-group-item small text-danger">${e.message}</li>`}}
148
+ </script>
149
+ {% endblock %}
@@ -0,0 +1,239 @@
1
+ {% extends "layouts/main.html" %}
2
+
3
+ {% block title %}WebSocket — {{ config.app.name | default(value='FuzionX') }}{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .ws-main { display: grid; grid-template-columns: 260px 1fr 180px; height: calc(100vh - 120px); overflow: hidden; }
8
+ .sidebar { overflow-y: auto; }
9
+ .chat-col { display: flex; flex-direction: column; overflow: hidden; }
10
+ .messages { flex: 1; overflow-y: auto; min-height: 0; }
11
+ .msg { max-width: 80%; }
12
+ .msg.sent { margin-left: auto; }
13
+ .msg.system { max-width: 100%; font-size: 12px; }
14
+ .quick-btn .btn { text-align: left; font-size: 12px; }
15
+ .badge-online { animation: pulse 2s infinite; }
16
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
17
+ .user-item { font-size: 12px; padding: 4px 8px; border-radius: 4px; }
18
+ .user-item.me { background: rgba(13,202,240,0.15); border: 1px solid rgba(13,202,240,0.3); }
19
+ </style>
20
+ {% endblock %}
21
+
22
+ {% block content %}
23
+ <div class="ws-main">
24
+ <!-- 좌측: 연결/Quick Send -->
25
+ <div class="sidebar bg-body-tertiary border-end p-3">
26
+ <h6 class="text-info"><i class="bi bi-wifi"></i> 연결 상태</h6>
27
+ <div class="alert alert-danger py-2 small" id="connStatus"><i class="bi bi-circle"></i> 연결 안됨</div>
28
+ <div class="mb-3">
29
+ <label class="form-label small">Namespace</label>
30
+ <select class="form-select form-select-sm" id="namespace">
31
+ <option value="/chat">/chat</option>
32
+ <option value="/">/</option>
33
+ </select>
34
+ </div>
35
+ <div class="d-flex gap-2 mb-3">
36
+ <button class="btn btn-info btn-sm flex-fill" onclick="connect()" id="btnConnect"><i class="bi bi-plug"></i> 연결</button>
37
+ <button class="btn btn-outline-danger btn-sm flex-fill" onclick="disconnect()" id="btnDisconnect" disabled><i class="bi bi-x-circle"></i> 끊기</button>
38
+ </div>
39
+
40
+ <h6 class="text-info mt-3"><i class="bi bi-send"></i> Quick Send</h6>
41
+ <div class="d-grid gap-1 quick-btn">
42
+ <button class="btn btn-outline-primary btn-sm" onclick="quickSend('message',{text:'Hello!'})"><i class="bi bi-chat-dots"></i> message (나 제외 전체)</button>
43
+ <button class="btn btn-outline-warning btn-sm" onclick="quickSend('broadcast',{text:'공지사항!'})"><i class="bi bi-megaphone"></i> broadcast (나 제외 전체)</button>
44
+ <button class="btn btn-outline-secondary btn-sm" onclick="quickSend('typing',{})"><i class="bi bi-keyboard"></i> typing (나 제외)</button>
45
+ <button class="btn btn-outline-info btn-sm" onclick="quickSend('userlist',{})"><i class="bi bi-people"></i> 접속자 목록 요청</button>
46
+ </div>
47
+
48
+ <h6 class="text-info mt-3"><i class="bi bi-journal-text"></i> 이벤트 로그</h6>
49
+ <pre class="bg-black text-success rounded p-2" style="max-height:180px;overflow-y:auto;font-size:11px" id="eventLog">Ready...</pre>
50
+ </div>
51
+
52
+ <!-- 중앙: 채팅 -->
53
+ <div class="chat-col">
54
+ <div class="messages p-3" id="messages"></div>
55
+ <div class="border-top p-2 d-flex gap-2">
56
+ <select class="form-select form-select-sm" style="width:130px" id="eventType">
57
+ <option value="message">message</option>
58
+ <option value="broadcast">broadcast</option>
59
+ <option value="typing">typing</option>
60
+ </select>
61
+ <input type="text" class="form-control form-control-sm" placeholder="메시지 입력..." id="msgInput" onkeydown="if(event.key==='Enter')sendMsg()">
62
+ <button class="btn btn-info btn-sm" onclick="sendMsg()"><i class="bi bi-send-fill"></i></button>
63
+ </div>
64
+ </div>
65
+
66
+ <!-- 우측: 접속자 목록 -->
67
+ <div class="bg-body-tertiary border-start p-3" style="overflow-y:auto">
68
+ <h6 class="text-info"><i class="bi bi-people-fill"></i> 접속자 <span class="badge bg-info text-dark" id="onlineCount">0</span></h6>
69
+ <div id="userList" class="d-grid gap-1"></div>
70
+ </div>
71
+ </div>
72
+ {% endblock %}
73
+
74
+ {% block scripts %}
75
+ <script>
76
+ let ws = null;
77
+ let mySessionId = null;
78
+ const messagesEl = document.getElementById('messages');
79
+ const eventLogEl = document.getElementById('eventLog');
80
+ let logLines = [];
81
+
82
+ function log(m) {
83
+ logLines.push(`[${new Date().toLocaleTimeString()}] ${m}`);
84
+ if (logLines.length > 100) logLines.shift();
85
+ eventLogEl.textContent = logLines.join('\n');
86
+ eventLogEl.scrollTop = eventLogEl.scrollHeight;
87
+ }
88
+
89
+ function addMsg(type, content, cls) {
90
+ const d = document.createElement('div');
91
+ d.className = `msg ${cls} mb-2 p-2 rounded border small`;
92
+ if (cls === 'sent') d.classList.add('bg-info-subtle', 'border-info');
93
+ else if (cls === 'received') d.classList.add('bg-body-tertiary');
94
+ else d.classList.add('bg-body-secondary', 'text-center', 'mx-auto');
95
+ const icons = { message:'bi-chat-dots', broadcast:'bi-megaphone', typing:'bi-keyboard', system:'bi-info-circle', userlist:'bi-people' };
96
+ const icon = icons[type] || 'bi-envelope';
97
+ const cs = typeof content === 'object' ? JSON.stringify(content, null, 2) : content;
98
+ d.innerHTML = `<div class="text-info fw-bold" style="font-size:10px"><i class="bi ${icon}"></i> ${type}</div><div>${cs}</div><div class="text-muted" style="font-size:10px">${new Date().toLocaleTimeString()}</div>`;
99
+ messagesEl.appendChild(d);
100
+ messagesEl.scrollTop = messagesEl.scrollHeight;
101
+ }
102
+
103
+ function updateUserList(data) {
104
+ document.getElementById('onlineCount').textContent = data.count || 0;
105
+ const ul = document.getElementById('userList');
106
+ ul.innerHTML = '';
107
+ (data.sessions || []).forEach(sid => {
108
+ const d = document.createElement('div');
109
+ d.className = 'user-item bg-body-secondary mb-1';
110
+ d.innerHTML = `<i class="bi bi-person-circle text-info"></i> ${sid}`;
111
+ ul.appendChild(d);
112
+ });
113
+ }
114
+
115
+ async function connect() {
116
+ const ns = document.getElementById('namespace').value;
117
+ const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
118
+ const url = `${wsProto}//${location.host}/ws${ns}`;
119
+ log(`연결 시도: ${url}`);
120
+ addMsg('system', `연결 시도: ${url}`, 'system');
121
+
122
+ try {
123
+ if (window.aspEnabled) {
124
+ // WASM FuzionXSocket — ASP transport 암/복호화 자동 적용
125
+ await window._aspReady;
126
+ ws = new window.FuzionXSocket(url, '{{ config.bridge.asp.master_secret }}', true);
127
+
128
+ ws.on('connect', () => {
129
+ log('✅ 연결 (ASP 암호화)');
130
+ document.getElementById('connStatus').className = 'alert alert-success py-2 small';
131
+ document.getElementById('connStatus').innerHTML = `<i class="bi bi-circle-fill badge-online"></i> 연결됨 — ASP 🔐 (${ns})`;
132
+ document.getElementById('btnConnect').disabled = true;
133
+ document.getElementById('btnDisconnect').disabled = false;
134
+ addMsg('system', '연결 성공 (ASP 암호화)', 'system');
135
+ });
136
+
137
+ ws.on('welcome', (data) => {
138
+ log(`← welcome: ${JSON.stringify(data)}`);
139
+ addMsg('system', `입장: ${JSON.stringify(data)}`, 'system');
140
+ setTimeout(() => { if (ws && ws.is_connected()) ws.send('userlist', {}); }, 500);
141
+ });
142
+
143
+ ws.on('message', (data) => {
144
+ const str = typeof data === 'object' ? JSON.stringify(data) : data;
145
+ log(`← ${str.slice(0, 200)}`);
146
+ if (data?.type === 'userlist') { updateUserList(data.data || data); return; }
147
+ if (data?.type === 'typing') { log(`✏️ 타이핑 중...`); return; }
148
+ addMsg(data?.type || 'msg', data?.data || data, 'received');
149
+ });
150
+
151
+ ws.on('userlist', (data) => { updateUserList(data); });
152
+ ws.on('typing', () => { log('✏️ 타이핑 중...'); });
153
+ ws.on('broadcast', (data) => { addMsg('broadcast', data, 'received'); });
154
+
155
+ ws.on('disconnect', (info) => {
156
+ log(`🔌 종료: ${JSON.stringify(info)}`);
157
+ document.getElementById('connStatus').className = 'alert alert-danger py-2 small';
158
+ document.getElementById('connStatus').innerHTML = `<i class="bi bi-circle"></i> 연결 안됨`;
159
+ document.getElementById('btnConnect').disabled = false;
160
+ document.getElementById('btnDisconnect').disabled = true;
161
+ addMsg('system', `종료: ${JSON.stringify(info)}`, 'system');
162
+ updateUserList({ count: 0, sessions: [] });
163
+ });
164
+
165
+ ws.on('error', () => { log('❌ 에러'); addMsg('system', '에러', 'system'); });
166
+ ws.connect();
167
+ } else {
168
+ // Plain WebSocket (ASP 비활성)
169
+ ws = new WebSocket(url);
170
+ ws.onopen = () => {
171
+ log('✅ 연결');
172
+ document.getElementById('connStatus').className = 'alert alert-success py-2 small';
173
+ document.getElementById('connStatus').innerHTML = `<i class="bi bi-circle-fill badge-online"></i> 연결됨 (${ns})`;
174
+ document.getElementById('btnConnect').disabled = true;
175
+ document.getElementById('btnDisconnect').disabled = false;
176
+ addMsg('system', '연결 성공', 'system');
177
+ setTimeout(() => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({type:'userlist',data:{}})); }, 500);
178
+ };
179
+ ws.onmessage = e => {
180
+ log(`← ${e.data.slice(0,200)}`);
181
+ let p; try { p = JSON.parse(e.data); } catch { p = e.data; }
182
+ if (p?.type === 'userlist') { updateUserList(p.data); return; }
183
+ if (p?.type === 'typing') { log(`✏️ 타이핑 중...`); return; }
184
+ addMsg(p?.type || 'msg', p?.data || p, 'received');
185
+ };
186
+ ws.onclose = e => {
187
+ log(`🔌 종료: ${e.code}`);
188
+ document.getElementById('connStatus').className = 'alert alert-danger py-2 small';
189
+ document.getElementById('connStatus').innerHTML = `<i class="bi bi-circle"></i> 연결 안됨`;
190
+ document.getElementById('btnConnect').disabled = false;
191
+ document.getElementById('btnDisconnect').disabled = true;
192
+ addMsg('system', `종료: ${e.code}`, 'system');
193
+ ws = null;
194
+ updateUserList({ count: 0, sessions: [] });
195
+ };
196
+ ws.onerror = () => { log('❌ 에러'); addMsg('system', '에러', 'system'); };
197
+ }
198
+ } catch (err) { log(`❌ ${err.message}`); }
199
+ }
200
+
201
+ function disconnect() {
202
+ if (!ws) return;
203
+ if (window.aspEnabled) { ws.disconnect(); }
204
+ else { ws.close(1000, 'User'); }
205
+ }
206
+
207
+ function sendMsg() {
208
+ if (!ws) return log('⚠️ 미연결');
209
+ if (window.aspEnabled && !ws.is_connected()) return log('⚠️ 미연결');
210
+ if (!window.aspEnabled && ws.readyState !== 1) return log('⚠️ 미연결');
211
+
212
+ const type = document.getElementById('eventType').value;
213
+ const text = document.getElementById('msgInput').value.trim();
214
+ if (!text && (type === 'message' || type === 'broadcast')) return;
215
+
216
+ if (window.aspEnabled) {
217
+ ws.send(type, { text });
218
+ } else {
219
+ ws.send(JSON.stringify({ type, data: { text } }));
220
+ }
221
+ log(`→ ${type}: ${text}`);
222
+ if (type !== 'typing') addMsg(type, { text }, 'sent');
223
+ document.getElementById('msgInput').value = '';
224
+ }
225
+
226
+ function quickSend(type, data) {
227
+ if (!ws) return log('⚠️ 미연결');
228
+ if (window.aspEnabled) {
229
+ if (!ws.is_connected()) return log('⚠️ 미연결');
230
+ ws.send(type, data);
231
+ } else {
232
+ if (ws.readyState !== 1) return log('⚠️ 미연결');
233
+ ws.send(JSON.stringify({ type, data }));
234
+ }
235
+ log(`→ ${JSON.stringify({ type, data })}`);
236
+ if (type !== 'typing' && type !== 'userlist') addMsg(type, data, 'sent');
237
+ }
238
+ </script>
239
+ {% endblock %}
@@ -0,0 +1,8 @@
1
+ <footer class="bg-body-tertiary border-top py-2 mt-4">
2
+ <div class="container-fluid text-center small text-muted">
3
+ <i class="bi bi-lightning-charge-fill text-info"></i>
4
+ {{ config.app.name | default(value='FuzionX') }} —
5
+ Theme: {{ theme | default(value='default') }} ·
6
+ Locale: {{ locale | default(value='ko') }}
7
+ </div>
8
+ </footer>
@@ -0,0 +1,20 @@
1
+ <nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
2
+ <div class="container-fluid">
3
+ <a class="navbar-brand nav-brand" href="/"><i class="bi bi-lightning-charge-fill"></i> FuzionX</a>
4
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav">
5
+ <span class="navbar-toggler-icon"></span>
6
+ </button>
7
+ <div class="collapse navbar-collapse" id="mainNav">
8
+ <ul class="navbar-nav">
9
+ <li class="nav-item"><a class="nav-link" href="/"><i class="bi bi-speedometer2"></i> Dashboard</a></li>
10
+ <li class="nav-item"><a class="nav-link" href="/test/websocket"><i class="bi bi-plug"></i> WebSocket</a></li>
11
+ <li class="nav-item"><a class="nav-link" href="/test/upload"><i class="bi bi-cloud-upload"></i> Upload</a></li>
12
+ <li class="nav-item"><a class="nav-link" href="/test/i18n"><i class="bi bi-translate"></i> i18n</a></li>
13
+ <li class="nav-item"><a class="nav-link" href="/docs"><i class="bi bi-file-earmark-code"></i> Swagger</a></li>
14
+ </ul>
15
+ <ul class="navbar-nav ms-auto">
16
+ <li class="nav-item"><span class="nav-link text-muted small"><i class="bi bi-gear"></i> {{ theme | default(value='default') }} · {{ locale | default(value='ko') }}</span></li>
17
+ </ul>
18
+ </div>
19
+ </div>
20
+ </nav>