@fuzionx/framework 0.1.45 β†’ 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 (92) 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/lib/core/Application.js +425 -138
  88. package/lib/core/Context.js +540 -236
  89. package/lib/middleware/auth.js +1 -1
  90. package/lib/middleware/csrf.js +1 -1
  91. package/lib/middleware/session.js +5 -4
  92. package/package.json +2 -2
@@ -0,0 +1,48 @@
1
+ import { Service } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * UserService β€” SSR μ‚¬μš©μž μ„œλΉ„μŠ€
5
+ *
6
+ * μ‚¬μš©μž ν”„λ‘œν•„ 쑰회 및 μˆ˜μ • λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 처리.
7
+ * λΉ„λ°€λ²ˆν˜Έ λ³€κ²½ μ‹œ bcrypt ν•΄μ‹œ 적용.
8
+ *
9
+ * @extends Service
10
+ */
11
+ export default class UserService extends Service {
12
+ /**
13
+ * μ‚¬μš©μž 단건 쑰회
14
+ *
15
+ * @param {number} id - μ‚¬μš©μž ID
16
+ * @returns {Promise<import('../../../database/models/User.js').default|null>} μ‚¬μš©μž λ˜λŠ” null
17
+ */
18
+ async find(id) {
19
+ return this.db.User.find(id);
20
+ }
21
+
22
+ /**
23
+ * μ‚¬μš©μž ν”„λ‘œν•„ μˆ˜μ •
24
+ *
25
+ * name, email, password 쀑 λ³€κ²½λœ ν•„λ“œλ§Œ μ—…λ°μ΄νŠΈ.
26
+ * passwordκ°€ μžˆμ„ 경우 bcrypt ν•΄μ‹œ ν›„ μ €μž₯.
27
+ *
28
+ * @param {number} id - μ‚¬μš©μž ID
29
+ * @param {Object} data - μˆ˜μ •ν•  데이터
30
+ * @param {string} [data.name] - 이름
31
+ * @param {string} [data.email] - 이메일
32
+ * @param {string} [data.password] - μƒˆ λΉ„λ°€λ²ˆν˜Έ (평문 β†’ bcrypt ν•΄μ‹œ)
33
+ * @returns {Promise<import('../../../database/models/User.js').default>} μˆ˜μ •λœ μ‚¬μš©μž
34
+ * @throws {AppError} 404 β€” μ‚¬μš©μž μ—†μŒ
35
+ */
36
+ async update(id, data) {
37
+ const user = await this.db.User.find(id);
38
+ if (!user) throw this.error('User not found', 404);
39
+
40
+ const updates = {};
41
+ if (data.name) updates.name = data.name;
42
+ if (data.email) updates.email = data.email;
43
+ if (data.password) updates.password = this.app.hash.bcrypt(data.password);
44
+
45
+ await user.update(updates);
46
+ return user;
47
+ }
48
+ }
@@ -0,0 +1,11 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}404{% endblock %}
3
+ {% block content %}
4
+ <div class="auth-container">
5
+ <div class="glass-card auth-card">
6
+ <div class="auth-logo">404</div>
7
+ <p class="auth-subtitle">{{ t(key="page.404", default="νŽ˜μ΄μ§€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€") }}</p>
8
+ <a href="/" class="btn btn-primary btn-full">{{ t(key="btn.go_home", default="ν™ˆμœΌλ‘œ") }}</a>
9
+ </div>
10
+ </div>
11
+ {% endblock %}
@@ -0,0 +1,48 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}500{% endblock %}
3
+ {% block content %}
4
+ <div class="auth-container">
5
+ <div class="glass-card auth-card">
6
+ <div class="auth-logo">500</div>
7
+ <p class="auth-subtitle">{{ t(key="page.500", default="μ„œλ²„ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€") }}</p>
8
+
9
+ {# 개발 λͺ¨λ“œ: μ—λŸ¬ λ©”μ‹œμ§€ + μŠ€νƒ 트레이슀 ν‘œμ‹œ #}
10
+ {% if config.debug and error %}
11
+ <div class="error-detail">
12
+ <p class="error-message">{{ error.message }}</p>
13
+ {% if error.stack %}
14
+ <pre class="error-stack">{{ error.stack }}</pre>
15
+ {% endif %}
16
+ </div>
17
+ {% endif %}
18
+
19
+ <a href="/" class="btn btn-primary btn-full">{{ t(key="btn.go_home", default="ν™ˆμœΌλ‘œ") }}</a>
20
+ </div>
21
+ </div>
22
+
23
+ <style>
24
+ .error-detail {
25
+ margin: 1.5rem 0;
26
+ text-align: left;
27
+ }
28
+ .error-message {
29
+ color: var(--danger, #e74c3c);
30
+ font-weight: 600;
31
+ margin-bottom: 0.75rem;
32
+ word-break: break-all;
33
+ }
34
+ .error-stack {
35
+ background: rgba(0, 0, 0, 0.3);
36
+ color: var(--text-secondary, #aaa);
37
+ padding: 1rem;
38
+ border-radius: var(--radius-sm, 8px);
39
+ font-size: 0.75rem;
40
+ line-height: 1.6;
41
+ overflow-x: auto;
42
+ white-space: pre-wrap;
43
+ word-break: break-all;
44
+ max-height: 400px;
45
+ overflow-y: auto;
46
+ }
47
+ </style>
48
+ {% endblock %}
@@ -0,0 +1,96 @@
1
+ <!DOCTYPE html>
2
+ <html lang="{{ locale }}">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}FuzionX{% endblock %}</title>
7
+ <meta name="description" content="{% block description %}FuzionX β€” Rust-powered full-stack Node.js framework for production-grade applications{% endblock %}">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/public/css/style.css">
11
+ <link rel="stylesheet" href="/public/css/landing.css">
12
+ <link rel="stylesheet" href="/public/css/fx-ui.css">
13
+ {% block head %}{% endblock %}
14
+ <script>
15
+ window.__FX__ENC_ENABLED = {{ config.bridge.asp.enabled | default(value=false) }} || {{ config.app.asp.enabled | default(value=false) }}
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div class="orb orb-1"></div>
20
+ <div class="orb orb-2"></div>
21
+ <div class="orb orb-3"></div>
22
+
23
+ <nav class="navbar">
24
+ <div class="nav-container">
25
+ <a href="/" class="nav-brand">FuzionX</a>
26
+ <div class="nav-links">
27
+ <a href="/" class="nav-link">{{ t(key="nav.home", default="Home") }}</a>
28
+ <a href="/features" class="nav-link">{{ t(key="nav.features", default="Features") }}</a>
29
+ <a href="/chat" class="nav-link">{{ t(key="nav.chat", default="Chat Demo") }}</a>
30
+ <a href="/board" class="nav-link">{{ t(key="nav.board", default="κ²Œμ‹œνŒ") }}</a>
31
+ {% if auth.user %}
32
+ <a href="/profile" class="nav-link">{{ t(key="nav.profile", default="ν”„λ‘œν•„") }}</a>
33
+ <form action="/logout" method="POST" class="nav-logout">
34
+ <button type="submit" class="nav-link btn-link">{{ t(key="nav.logout", default="λ‘œκ·Έμ•„μ›ƒ") }}</button>
35
+ </form>
36
+ {% else %}
37
+ <a href="/login" class="nav-link">{{ t(key="nav.login", default="둜그인") }}</a>
38
+ <a href="/register" class="nav-link">{{ t(key="nav.register", default="νšŒμ›κ°€μž…") }}</a>
39
+ {% endif %}
40
+ <div class="lang-switcher">
41
+ <button class="lang-switcher-btn" onclick="this.parentElement.classList.toggle('open')">
42
+ {{ locale | upper }}
43
+ <svg width="10" height="6" viewBox="0 0 10 6" fill="currentColor"><path d="M1 1l4 4 4-4"/></svg>
44
+ </button>
45
+ <ul class="lang-switcher-menu">
46
+ {% for loc in locales %}
47
+ <li class="lang-switcher-item{% if loc == locale %} active{% endif %}">
48
+ <a href="?lang={{ loc }}" class="lang-switcher-link">{{ loc | upper }}</a>
49
+ </li>
50
+ {% endfor %}
51
+ </ul>
52
+ </div>
53
+ </div>
54
+ <button class="nav-mobile-toggle" onclick="var nl=document.querySelector('.nav-links'),bd=document.querySelector('.nav-backdrop');nl.classList.toggle('open');bd.classList.toggle('open')" aria-label="Menu">
55
+ <span></span><span></span><span></span>
56
+ </button>
57
+ </div>
58
+ </nav>
59
+ <div class="nav-backdrop" onclick="document.querySelector('.nav-links').classList.remove('open');this.classList.remove('open')"></div>
60
+
61
+ <main class="main-content has-nav">
62
+ {% block flash_messages %}
63
+ {% if flash.error %}
64
+ <div class="container" style="margin-bottom: -1rem;">
65
+ <div class="alert alert-error">{{ flash.error }}</div>
66
+ </div>
67
+ {% endif %}
68
+ {% if flash.success %}
69
+ <div class="container" style="margin-bottom: -1rem;">
70
+ <div class="alert alert-success">{{ flash.success }}</div>
71
+ </div>
72
+ {% endif %}
73
+ {% endblock flash_messages %}
74
+ {% block content %}{% endblock %}
75
+ </main>
76
+
77
+ <script src="/public/js/fx-client.js"></script>
78
+ {% if auth.user and _fx_client_secret %}
79
+ <script>
80
+ FxClient.init('{{ _fx_client_secret | safe }}', '{{ _fx_asp_secret | safe }}', '{{ _fx_asp_header | safe }}');
81
+ setInterval(function() {
82
+ aspFetch('/api/heartbeat')
83
+ .then(function(r) { if (r && r.status === 401) window.location.href = '/login'; })
84
+ .catch(function() {});
85
+ }, 5 * 60 * 1000);
86
+ </script>
87
+ {% elif config.bridge.asp.enabled is defined and config.bridge.asp.enabled %}
88
+ <script>
89
+ FxClient.initPublic('{{ config.bridge.asp.master_secret }}', '{{ config.bridge.asp.header_signal | default(value="Ruxy-Enc-Mode") }}');
90
+ </script>
91
+ {% endif %}
92
+ <script>document.addEventListener('click',function(e){document.querySelectorAll('.lang-switcher.open').forEach(function(el){if(!el.contains(e.target))el.classList.remove('open')})});</script>
93
+ <script src="/public/js/fx-ui.js"></script>
94
+ {% block scripts %}{% endblock %}
95
+ </body>
96
+ </html>
@@ -0,0 +1,240 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}{% if post %}{{ t(key="board.edit_title", default="κΈ€ μˆ˜μ •") }}{% else %}{{ t(key="board.new_title", default="μƒˆ κΈ€ μž‘μ„±") }}{% endif %} β€” FuzionX{% endblock %}
3
+ {% block flash_messages %}{% endblock %}
4
+ {% block content %}
5
+ <div class="container">
6
+ <div class="glass-card">
7
+ <h1 class="page-title">{% if post %}{{ t(key="board.edit_title", default="κΈ€ μˆ˜μ •") }}{% else %}{{ t(key="board.new_title", default="μƒˆ κΈ€ μž‘μ„±") }}{% endif %}</h1>
8
+
9
+ <form class="form" action="{% if post %}/board/{{ post.id }}{% else %}/board{% endif %}" method="POST" enctype="multipart/form-data" onsubmit="return handleUpload(event)">
10
+ <div class="form-group">
11
+ <label for="title">{{ t(key="label.title", default="제λͺ©") }}</label>
12
+ <input type="text" id="title" name="title" value="{% if post %}{{ post.title }}{% endif %}" required>
13
+ </div>
14
+
15
+ <div class="form-group">
16
+ <label for="content">{{ t(key="label.content", default="λ‚΄μš©") }}</label>
17
+ {# ── λ§ˆν¬λ‹€μš΄ 에디터 νˆ΄λ°” (ν”„λ ˆμž„μ›Œν¬ κΈ°λŠ₯ μ‹œμ—°) ── #}
18
+ <div class="editor-toolbar" id="editorToolbar">
19
+ <button type="button" onclick="editorCmd('bold')" title="Bold"><b>B</b></button>
20
+ <button type="button" onclick="editorCmd('italic')" title="Italic"><i>I</i></button>
21
+ <button type="button" onclick="editorCmd('underline')" title="Underline"><u>U</u></button>
22
+ <div class="separator"></div>
23
+ <button type="button" onclick="editorInsert('# ')" title="H1">H1</button>
24
+ <button type="button" onclick="editorInsert('## ')" title="H2">H2</button>
25
+ <div class="separator"></div>
26
+ <button type="button" onclick="editorInsert('- ')" title="List">βŠ™</button>
27
+ <button type="button" onclick="editorInsert('> ')" title="Quote">❝</button>
28
+ <button type="button" onclick="editorInsert('```\n\n```')" title="Code">⟨/⟩</button>
29
+ <div class="separator"></div>
30
+ <button type="button" onclick="editorInsert('[링크](url)')" title="Link">πŸ”—</button>
31
+ </div>
32
+ <textarea id="content" name="content" rows="14" class="has-toolbar" required>{% if post %}{{ post.content }}{% endif %}</textarea>
33
+ </div>
34
+
35
+ {# ── κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ λͺ©λ‘ (μˆ˜μ • λͺ¨λ“œ, 썸넀일 + κ°œλ³„ μ‚­μ œ) ── #}
36
+ {% if post and files and files | length > 0 %}
37
+ <div class="form-group">
38
+ <label>{{ t(key="board.attached_files", default="κΈ°μ‘΄ μ²¨λΆ€νŒŒμΌ") }}</label>
39
+ <div class="existing-files-grid">
40
+ {% for file in files %}
41
+ <div class="existing-file-card">
42
+ <div class="existing-file-thumb">
43
+ {% if file.thumbUrl %}
44
+ <img src="{{ file.thumbUrl }}" alt="{{ file.original_name }}" loading="lazy">
45
+ {% elif file.isImage %}
46
+ <img src="{{ file.url }}" alt="{{ file.original_name }}" loading="lazy">
47
+ {% elif file.isVideo %}
48
+ <div class="thumb-placeholder">🎬</div>
49
+ {% else %}
50
+ <div class="thumb-placeholder">πŸ“„</div>
51
+ {% endif %}
52
+ </div>
53
+ <div class="existing-file-info">
54
+ <span class="file-name" title="{{ file.original_name }}">{{ file.original_name }}</span>
55
+ <button type="button" class="file-remove" title="μ‚­μ œ"
56
+ onclick="if(confirm('μ‚­μ œ?')){var f=document.createElement('form');f.method='POST';f.action='/board/attachment/{{ file.id }}/delete';document.body.appendChild(f);f.submit();}">βœ•</button>
57
+ </div>
58
+ </div>
59
+ {% endfor %}
60
+ </div>
61
+ </div>
62
+ {% endif %}
63
+
64
+ {# ── μƒˆ 파일 μ—…λ‘œλ“œ (λ“œλž˜κ·Έ&λ“œλ‘­ + 클릭) ── #}
65
+ <div class="form-group">
66
+ <label>{{ t(key="label.attachments", default="μ²¨λΆ€νŒŒμΌ") }}</label>
67
+ <div class="file-upload-area" id="dropZone" onclick="document.getElementById('fileInput').click()">
68
+ <div class="upload-icon">πŸ“Ž</div>
69
+ <div class="upload-text">{{ t(key="board.drop_files", default="클릭 λ˜λŠ” νŒŒμΌμ„ 여기에 λ“œλž˜κ·Έ") }}</div>
70
+ <div class="upload-hint">{{ t(key="board.file_hint", default="이미지, λΉ„λ””μ˜€, λ¬Έμ„œ 파일") }}</div>
71
+ </div>
72
+ <input type="file" id="fileInput" name="files" multiple style="display:none"
73
+ accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip">
74
+ <div class="file-list" id="fileList"></div>
75
+ </div>
76
+
77
+ {# ── μ—…λ‘œλ“œ μ§„ν–‰λ₯  ── #}
78
+ <div class="upload-progress" id="uploadProgress" style="display:none;margin:1rem 0">
79
+ <div class="progress-bar-track" style="background:rgba(255,255,255,0.15);border-radius:8px;height:14px;overflow:hidden">
80
+ <div class="progress-bar-fill" id="progressFill" style="height:100%;width:0;background:linear-gradient(90deg,#667eea,#764ba2);border-radius:8px;transition:width 0.3s ease"></div>
81
+ </div>
82
+ <div class="progress-info" style="display:flex;justify-content:space-between;margin-top:6px;font-size:0.85rem;color:#aaa">
83
+ <span id="progressText">0%</span>
84
+ <span id="progressSpeed"></span>
85
+ </div>
86
+ </div>
87
+
88
+ {% if flash.error %}
89
+ <div class="alert alert-error">{{ flash.error }}</div>
90
+ {% endif %}
91
+ {% if flash.success %}
92
+ <div class="alert alert-success">{{ flash.success }}</div>
93
+ {% endif %}
94
+
95
+ <div class="form-actions">
96
+ <button type="submit" class="btn btn-primary" id="submitBtn">{% if post %}{{ t(key="btn.edit", default="μˆ˜μ •") }}{% else %}{{ t(key="btn.create", default="μž‘μ„±") }}{% endif %}</button>
97
+ <a href="/board" class="btn btn-outline">{{ t(key="btn.cancel", default="μ·¨μ†Œ") }}</a>
98
+ </div>
99
+ </form>
100
+ </div>
101
+ </div>
102
+
103
+ <script>
104
+ /** 에디터 ν…μŠ€νŠΈ 감싸기 (Bold/Italic/Underline) */
105
+ function editorCmd(type) {
106
+ var ta = document.getElementById('content');
107
+ var start = ta.selectionStart, end = ta.selectionEnd;
108
+ var sel = ta.value.substring(start, end);
109
+ var wrap = { bold: ['**','**'], italic: ['_','_'], underline: ['__','__'] };
110
+ var w = wrap[type] || ['',''];
111
+ ta.setRangeText(w[0] + sel + w[1], start, end, 'end');
112
+ ta.focus();
113
+ }
114
+
115
+ /** 에디터 ν…μŠ€νŠΈ μ‚½μž… */
116
+ function editorInsert(text) {
117
+ var ta = document.getElementById('content');
118
+ ta.setRangeText(text, ta.selectionStart, ta.selectionStart, 'end');
119
+ ta.focus();
120
+ }
121
+
122
+ /** 파일 μ—…λ‘œλ“œ β€” λ“œλž˜κ·Έ&λ“œλ‘­ + 미리보기 */
123
+ (function() {
124
+ var drop = document.getElementById('dropZone');
125
+ var input = document.getElementById('fileInput');
126
+ var list = document.getElementById('fileList');
127
+
128
+ function formatSize(bytes) {
129
+ if (bytes < 1024) return bytes + ' B';
130
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
131
+ return (bytes / 1048576).toFixed(1) + ' MB';
132
+ }
133
+
134
+ function renderFiles() {
135
+ list.innerHTML = '';
136
+ if (!input.files.length) return;
137
+ for (var i = 0; i < input.files.length; i++) {
138
+ var f = input.files[i];
139
+ var div = document.createElement('div');
140
+ div.className = 'file-item';
141
+
142
+ var preview = '';
143
+ if (f.type.startsWith('image/')) {
144
+ var url = URL.createObjectURL(f);
145
+ preview = '<img src="' + url + '" class="file-preview">';
146
+ } else if (f.type.startsWith('video/')) {
147
+ preview = '<span class="file-preview-icon">🎬</span>';
148
+ } else {
149
+ preview = '<span class="file-preview-icon">πŸ“„</span>';
150
+ }
151
+
152
+ div.innerHTML = preview + '<span class="file-name">' + f.name + '</span><span class="file-size">' + formatSize(f.size) + '</span>';
153
+ list.appendChild(div);
154
+ }
155
+ }
156
+
157
+ input.addEventListener('change', renderFiles);
158
+
159
+ ['dragenter','dragover'].forEach(function(e) {
160
+ drop.addEventListener(e, function(ev) { ev.preventDefault(); drop.classList.add('dragover'); });
161
+ });
162
+ ['dragleave','drop'].forEach(function(e) {
163
+ drop.addEventListener(e, function(ev) { ev.preventDefault(); drop.classList.remove('dragover'); });
164
+ });
165
+ drop.addEventListener('drop', function(ev) {
166
+ input.files = ev.dataTransfer.files;
167
+ renderFiles();
168
+ });
169
+ })();
170
+
171
+ /** XHR μ—…λ‘œλ“œ β€” μ§„ν–‰λ₯  + μžλ™ λ¦¬λ‹€μ΄λ ‰νŠΈ */
172
+ function handleUpload(e) {
173
+ var fileInput = document.getElementById('fileInput');
174
+ if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
175
+ return true; // 파일 μ—†μœΌλ©΄ κΈ°λ³Έ 폼 제좜
176
+ }
177
+
178
+ e.preventDefault();
179
+ var form = e.target;
180
+ var btn = document.getElementById('submitBtn');
181
+ var progress = document.getElementById('uploadProgress');
182
+ var fill = document.getElementById('progressFill');
183
+ var pText = document.getElementById('progressText');
184
+ var pSpeed = document.getElementById('progressSpeed');
185
+
186
+ btn.disabled = true;
187
+ btn.textContent = 'μ—…λ‘œλ“œ 쀑...';
188
+ progress.style.display = 'block';
189
+
190
+ var formData = new FormData(form);
191
+ var xhr = new XMLHttpRequest();
192
+ var startTime = Date.now();
193
+
194
+ xhr.upload.onprogress = function(ev) {
195
+ if (!ev.lengthComputable) return;
196
+ var pct = Math.round((ev.loaded / ev.total) * 100);
197
+ fill.style.width = pct + '%';
198
+ pText.textContent = pct + '%';
199
+
200
+ var elapsed = (Date.now() - startTime) / 1000;
201
+ if (elapsed > 0.5) {
202
+ var bps = ev.loaded / elapsed;
203
+ if (bps > 1048576) {
204
+ pSpeed.textContent = (bps / 1048576).toFixed(1) + ' MB/s';
205
+ } else {
206
+ pSpeed.textContent = (bps / 1024).toFixed(0) + ' KB/s';
207
+ }
208
+ }
209
+ };
210
+
211
+ xhr.onload = function() {
212
+ if (xhr.status >= 200 && xhr.status < 400) {
213
+ fill.style.width = '100%';
214
+ pText.textContent = 'μ—…λ‘œλ“œ μ™„λ£Œ!';
215
+ pSpeed.textContent = '';
216
+ btn.textContent = 'πŸ”„ 썸넀일 μΆ”μΆœμ€‘...';
217
+ setTimeout(function() {
218
+ window.location.href = xhr.responseURL || '/board';
219
+ }, 1500);
220
+ } else {
221
+ btn.disabled = false;
222
+ btn.textContent = 'μž¬μ‹œλ„';
223
+ pText.textContent = '였λ₯˜ λ°œμƒ';
224
+ fill.style.background = '#e74c3c';
225
+ }
226
+ };
227
+
228
+ xhr.onerror = function() {
229
+ btn.disabled = false;
230
+ btn.textContent = 'μž¬μ‹œλ„';
231
+ pText.textContent = 'λ„€νŠΈμ›Œν¬ 였λ₯˜';
232
+ fill.style.background = '#e74c3c';
233
+ };
234
+
235
+ xhr.open('POST', form.action);
236
+ xhr.send(formData);
237
+ return false;
238
+ }
239
+ </script>
240
+ {% endblock %}
@@ -0,0 +1,73 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}{{ t(key="board.title", default="κ²Œμ‹œνŒ") }} β€” FuzionX{% endblock %}
3
+ {% block content %}
4
+ <div class="container">
5
+ <div class="glass-card">
6
+ <div class="page-header">
7
+ <h1 class="page-title">{{ t(key="board.title", default="κ²Œμ‹œνŒ") }}</h1>
8
+ <a href="/board/new" class="btn btn-primary">{{ t(key="btn.new_post", default="μƒˆ κΈ€") }}</a>
9
+ </div>
10
+
11
+ <div class="post-list">
12
+ {% for post in posts %}
13
+ <a href="/board/{{ post.id }}" class="post-card">
14
+ {% if post.thumbUrl %}
15
+ <div class="post-card-thumb">
16
+ <img src="{{ post.thumbUrl }}" alt="{{ post.title }}" loading="lazy">
17
+ </div>
18
+ {% endif %}
19
+ <div class="post-card-body">
20
+ <h3 class="post-card-title">
21
+ {% if post.status == "processing" %}<span class="badge badge-processing">πŸ”„ {{ t(key="board.processing", default="μ²˜λ¦¬μ€‘") }}</span> {% endif %}{{ post.title }}
22
+ </h3>
23
+ <div class="post-card-meta">
24
+ <span>{{ post.user_name | default(value="-") }}</span>
25
+ <span>{{ post.created_at }}</span>
26
+ </div>
27
+ </div>
28
+ </a>
29
+ {% endfor %}
30
+ {% if not posts or posts | length == 0 %}
31
+ <div class="post-list-empty">{{ t(key="board.empty", default="κ²Œμ‹œκΈ€μ΄ μ—†μŠ΅λ‹ˆλ‹€.") }}</div>
32
+ {% endif %}
33
+ </div>
34
+
35
+ {% set baseUrl = "/board" %}
36
+ {% include "partials/pagination.html" %}
37
+ </div>
38
+ </div>
39
+
40
+ {# processing κ²Œμ‹œκΈ€μ΄ 있으면 REST 폴링으둜 μƒνƒœ 확인 #}
41
+ <script>
42
+ (function() {
43
+ var badges = document.querySelectorAll('.badge-processing');
44
+ if (!badges.length) return;
45
+
46
+ // processing post ID μˆ˜μ§‘ (λΆ€λͺ¨ .post-card의 hrefμ—μ„œ μΆ”μΆœ)
47
+ var ids = [];
48
+ badges.forEach(function(b) {
49
+ var card = b.closest('.post-card');
50
+ if (card) {
51
+ var m = card.getAttribute('href').match(/\/board\/(\d+)/);
52
+ if (m) ids.push(m[1]);
53
+ }
54
+ });
55
+ if (!ids.length) return;
56
+
57
+ var timer = setInterval(function() {
58
+ aspFetch('/api/board/status?ids=' + ids.join(','))
59
+ .then(function(res) {
60
+ if (!res || !res.statuses) return;
61
+ var allDone = true;
62
+ for (var i = 0; i < ids.length; i++) {
63
+ if (res.statuses[ids[i]] === 'processing') { allDone = false; break; }
64
+ }
65
+ if (allDone) {
66
+ clearInterval(timer);
67
+ location.reload();
68
+ }
69
+ });
70
+ }, 5000);
71
+ })();
72
+ </script>
73
+ {% endblock %}
@@ -0,0 +1,148 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}{{ post.title }} β€” FuzionX{% endblock %}
3
+ {% block content %}
4
+ <div class="container">
5
+ <div class="glass-card">
6
+ <div class="page-header">
7
+ <h1 class="page-title">
8
+ {% if post.status == "processing" %}<span class="badge badge-processing">πŸ”„ {{ t(key="board.processing", default="μ²˜λ¦¬μ€‘") }}</span> {% endif %}{{ post.title }}
9
+ </h1>
10
+ {% if auth.user.id == post.user_id %}
11
+ <div class="page-actions">
12
+ <a href="/board/{{ post.id }}/edit" class="btn btn-outline">{{ t(key="btn.edit", default="μˆ˜μ •") }}</a>
13
+ <form action="/board/{{ post.id }}/delete" method="POST" style="display:inline">
14
+ <button type="submit" class="btn btn-danger" onclick="return confirm('{{ t(key="board.confirm_delete", default="μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?") }}')">{{ t(key="btn.delete", default="μ‚­μ œ") }}</button>
15
+ </form>
16
+ </div>
17
+ {% endif %}
18
+ </div>
19
+
20
+ <div class="post-meta">
21
+ <span>{% if author %}{{ author.name }}{% else %}-{% endif %}</span>
22
+ <span>{{ post.created_at }}</span>
23
+ </div>
24
+
25
+ <div class="post-content">{{ post.content }}</div>
26
+
27
+ {# ── λΉ„λ””μ˜€ 썸넀일 λ³Έλ¬Έ ν‘œμ‹œ ── #}
28
+ {% if files and files | length > 0 %}
29
+ {% for file in files %}
30
+ {% if file.isVideo and file.thumbUrl %}
31
+ <div class="inline-video">
32
+ <img src="{{ file.thumbUrl }}" alt="{{ file.original_name }}" style="width:100%;max-height:500px;object-fit:contain;border-radius:var(--radius-sm, 8px)">
33
+ </div>
34
+ {% endif %}
35
+ {% endfor %}
36
+ {% endif %}
37
+
38
+ {# ── μ²¨λΆ€νŒŒμΌ μ„Ήμ…˜ ── #}
39
+ {% if files and files | length > 0 %}
40
+ <div class="post-attachments">
41
+ <h3>πŸ“Ž {{ t(key="label.attachments", default="μ²¨λΆ€νŒŒμΌ") }} ({{ files | length }})</h3>
42
+ <div class="attachment-grid">
43
+ {% for file in files %}
44
+ <div class="attachment-card">
45
+ {% if file.isImage %}
46
+ {# 이미지: 썸넀일 ν‘œμ‹œ, 클릭 μ‹œ 원본 lightbox #}
47
+ <a href="{{ file.url }}" class="lightbox-trigger" data-type="image" target="_blank">
48
+ <div class="attachment-thumb">
49
+ <img src="{{ file.thumbUrl | default(value=file.url) }}" alt="{{ file.original_name }}" loading="lazy">
50
+ </div>
51
+ <div class="attachment-info">
52
+ <span class="attachment-name" title="{{ file.original_name }}">πŸ–ΌοΈ {{ file.original_name }}</span>
53
+ </div>
54
+ </a>
55
+ {% elif file.isVideo %}
56
+ {# λΉ„λ””μ˜€: ν”„λ ˆμž„ 썸넀일 + β–Ά μ˜€λ²„λ ˆμ΄ #}
57
+ <a href="{{ file.url }}" class="lightbox-trigger" data-type="video" target="_blank">
58
+ <div class="attachment-thumb">
59
+ {% if file.thumbUrl %}
60
+ <img src="{{ file.thumbUrl }}" alt="{{ file.original_name }}" loading="lazy">
61
+ {% else %}
62
+ <div class="thumb-placeholder">🎬</div>
63
+ {% endif %}
64
+ <div class="play-overlay">β–Ά</div>
65
+ </div>
66
+ <div class="attachment-info">
67
+ <span class="attachment-name" title="{{ file.original_name }}">🎬 {{ file.original_name }}</span>
68
+ </div>
69
+ </a>
70
+ {% else %}
71
+ {# 일반 파일: λ‹€μš΄λ‘œλ“œ 링크 #}
72
+ <a href="{{ file.url }}" class="attachment-download" download>
73
+ <div class="attachment-thumb">
74
+ <div class="thumb-placeholder">πŸ“„</div>
75
+ </div>
76
+ <div class="attachment-info">
77
+ <span class="attachment-name" title="{{ file.original_name }}">πŸ“„ {{ file.original_name }}</span>
78
+ </div>
79
+ </a>
80
+ {% endif %}
81
+ </div>
82
+ {% endfor %}
83
+ </div>
84
+ </div>
85
+ {% endif %}
86
+
87
+ <div class="post-footer">
88
+ <a href="/board" class="btn btn-outline">{{ t(key="btn.back", default="← λͺ©λ‘") }}</a>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ {# processing μƒνƒœλ©΄ REST 폴링으둜 μƒνƒœ 확인 #}
94
+ {% if post.status == "processing" %}
95
+ <script>
96
+ (function() {
97
+ var postId = '{{ post.id }}';
98
+ var timer = setInterval(function() {
99
+ aspFetch('/api/board/status?ids=' + postId)
100
+ .then(function(res) {
101
+ if (!res || !res.statuses) return;
102
+ if (res.statuses[postId] !== 'processing') {
103
+ clearInterval(timer);
104
+ location.reload();
105
+ }
106
+ });
107
+ }, 5000);
108
+ })();
109
+ </script>
110
+ {% endif %}
111
+
112
+ {# ── Lightbox λͺ¨λ‹¬ (이미지 원본 / λΉ„λ””μ˜€ μž¬μƒ) ── #}
113
+ <div class="lightbox-overlay" id="lightbox" style="display:none">
114
+ <div class="lightbox-content">
115
+ <button class="lightbox-close" onclick="closeLightbox()">βœ•</button>
116
+ <div id="lightboxBody"></div>
117
+ </div>
118
+ </div>
119
+
120
+ <script>
121
+ /** Lightbox β€” 이미지 원본 보기 / λΉ„λ””μ˜€ μž¬μƒ λͺ¨λ‹¬ */
122
+ document.querySelectorAll('.lightbox-trigger').forEach(function(el) {
123
+ el.addEventListener('click', function(e) {
124
+ e.preventDefault();
125
+ var url = this.getAttribute('href');
126
+ var type = this.getAttribute('data-type');
127
+ var body = document.getElementById('lightboxBody');
128
+
129
+ if (type === 'video') {
130
+ body.innerHTML = '<video controls autoplay style="max-width:100%;max-height:80vh"><source src="' + url + '"></video>';
131
+ } else {
132
+ body.innerHTML = '<img src="' + url + '" style="max-width:100%;max-height:80vh">';
133
+ }
134
+
135
+ document.getElementById('lightbox').style.display = 'flex';
136
+ });
137
+ });
138
+
139
+ function closeLightbox() {
140
+ document.getElementById('lightbox').style.display = 'none';
141
+ document.getElementById('lightboxBody').innerHTML = '';
142
+ }
143
+
144
+ document.getElementById('lightbox').addEventListener('click', function(e) {
145
+ if (e.target === this) closeLightbox();
146
+ });
147
+ </script>
148
+ {% endblock %}