tag.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /**
  2. * 2026.01.07 작성
  3. * 태그별 게시물 조회 JS
  4. */
  5. class ProcessTags
  6. {
  7. constructor()
  8. {
  9. this.loading = false;
  10. this.tags = document.getElementById('tags');
  11. this.posts = document.getElementById('posts');
  12. this.pagination = document.getElementById('pagination');
  13. }
  14. setActiveTag(target)
  15. {
  16. const links = this.tags.querySelectorAll('.tag-nav-link');
  17. links.forEach(link => link.classList.remove('active'));
  18. target.classList.add('active');
  19. }
  20. updateQueryString(tag, page)
  21. {
  22. const url = new URL(window.location.href);
  23. if (tag) {
  24. url.searchParams.set('name', tag);
  25. } else {
  26. url.searchParams.delete('name');
  27. }
  28. if (page > 1) {
  29. url.searchParams.set('page', page.toString());
  30. } else {
  31. url.searchParams.delete('page');
  32. }
  33. window.history.pushState({}, '', url.toString());
  34. }
  35. getTagFromQuery()
  36. {
  37. return new URLSearchParams(window.location.search).get('name')?.trim();
  38. }
  39. getPageFromQuery() {
  40. return parseInt(new URLSearchParams(window.location.search).get('page') || '1', 10) || 1;
  41. }
  42. findLinkByTag(tag)
  43. {
  44. if (!tag) {
  45. return null;
  46. }
  47. const links = this.tags.querySelectorAll('.tag-nav-link');
  48. for (const link of links) {
  49. const name = (link.dataset.name || link.textContent || '').trim();
  50. if (name === tag) {
  51. return link;
  52. }
  53. }
  54. return null;
  55. }
  56. async loadPosts(tag, page)
  57. {
  58. const params = new URLSearchParams({
  59. name: tag,
  60. page: page
  61. });
  62. this.updateQueryString(tag, page);
  63. const nav = this.findLinkByTag(tag);
  64. if (nav) {
  65. this.setActiveTag(nav);
  66. }
  67. this.posts.innerHTML = ' \
  68. <div class="text-center my-4"> \
  69. <div class="spinner-border" role="status"> \
  70. <span class="visually-hidden">Loading...</span> \
  71. </div> \
  72. </div> \
  73. ';
  74. try {
  75. if (this.loading) {
  76. return;
  77. }
  78. this.loading = true;
  79. const res = await fetch(`${BASE_URL}/tag/posts?${params.toString()}`, {
  80. headers: {
  81. 'X-Requested-With': 'XMLHttpRequest',
  82. 'Accept': 'application/json',
  83. }
  84. });
  85. if (!res.ok) {
  86. throw new Error();
  87. }
  88. const data = await res.json().then(r => r.data);
  89. this.renderPosts(data);
  90. this.renderPagination(data);
  91. } catch (error) {
  92. this.posts.innerHTML = `
  93. <p>
  94. 조회 중 오류가 발생했습니다.<br/>
  95. 잠시 후 다시 시도해주세요.
  96. </p>
  97. `;
  98. } finally {
  99. this.loading = false;
  100. }
  101. }
  102. async fetchPosts(e)
  103. {
  104. const target = e.target.closest?.('.tag-nav-link, .tag-sub-link, .page-link');
  105. if (!target) {
  106. return;
  107. }
  108. if (target.classList.contains('active') && target.classList.contains('tag-nav-link')) {
  109. this.posts.innerHTML = '<div class="text-center my-4">태그를 선택하면 게시글이 조회됩니다.</div>';
  110. this.pagination.innerHTML = '';
  111. target.classList.remove('active');
  112. this.updateQueryString('', 0);
  113. return;
  114. }
  115. let tag = (target.dataset.name || target.textContent || '').trim();
  116. let page = 1;
  117. if (target.classList.contains('page-link')) {
  118. e.preventDefault();
  119. tag = this.getTagFromQuery();
  120. page = parseInt(target.dataset.page || '1', 10) || 1;
  121. }
  122. return this.loadPosts(tag, page);
  123. }
  124. renderPosts(res)
  125. {
  126. if (!res || res.rows <= 0) {
  127. this.posts.innerHTML = '<p class="p-5 text-center">관련 게시물이 없습니다.</p>';
  128. return;
  129. }
  130. let html = "";
  131. res.list.forEach(function(item) {
  132. html += `
  133. <li class="list-group-item">
  134. <div class="row align-items-center">
  135. ${item.thumbnail ? `
  136. <div class="col col-lg-2">
  137. <a href="${BASE_URL}/board/photo/${item.id}" target="_blank" rel="noopener">
  138. <img src="${item.thumbnail}" alt="${item.subject}" class="img-fluid img-thumbnail"/>
  139. </a>
  140. </div>
  141. ` : ''}
  142. <div class="${item.thumbnail ? `col col-lg-10` : 'col'}">
  143. <dl class="mb-0">
  144. <dt>
  145. <div class="row">
  146. <div class="col">
  147. <h4>
  148. <a href="${BASE_URL}/board/photo/${item.id}" target="_blank" rel="noopener">
  149. ${item.subject}
  150. </a>
  151. </h4>
  152. </div>
  153. <div class="col-auto">
  154. <small>${item.createdAt}</small>
  155. </div>
  156. </div>
  157. </dt>
  158. <dd>
  159. <blockquote class="blockquote">
  160. ${item.content}
  161. </blockquote>
  162. </dd>
  163. <dd>${item.userName}</dd>
  164. <dd class="mb-0">
  165. ${item.tags.map(tag => `
  166. <span class="tag-sub-link" data-name="${tag.toString().replace(/"/g, '&quot;')}">
  167. #${tag}
  168. </span>
  169. `).join(',')}
  170. </dd>
  171. </dl>
  172. </div>
  173. </div>
  174. </li>
  175. `;
  176. });
  177. this.posts.innerHTML = html;
  178. }
  179. renderPagination(res)
  180. {
  181. const total = parseInt(res.total || '0', 0) || 0;
  182. const page = parseInt(res.page || '1', 10) || 1;
  183. const last = Math.ceil(total / 10);
  184. if (last <= 1) {
  185. return;
  186. }
  187. const make = (p, label, disabled = false, active = false) => `
  188. <li class="page-item ${disabled ? 'disabled' : ''} ${active ? 'active' : ''}">
  189. <a class="page-link" href="#" data-page="${p}">${label}</a>
  190. </li>
  191. `;
  192. let html = `<hr/><nav><ul class="pagination">`;
  193. html += make(page - 1, '‹', page <= 1);
  194. // 간단히 1~last 다 찍으면 페이지 많을 때 별로라서 window 방식(최대 7개)
  195. const windowSize = 7;
  196. let start = Math.max(1, page - Math.floor(windowSize / 2));
  197. let end = Math.min(last, start + windowSize - 1);
  198. start = Math.max(1, end - windowSize + 1);
  199. for (let p = start; p <= end; p++) {
  200. html += make(p, String(p), false, p === page);
  201. }
  202. html += make(page + 1, '›', page >= last);
  203. html += `</ul></nav>`;
  204. this.pagination.innerHTML = html;
  205. }
  206. initFromUrl()
  207. {
  208. const tag = this.getTagFromQuery();
  209. if (!tag) {
  210. return;
  211. }
  212. const link = this.findLinkByTag(tag);
  213. if (!link) {
  214. return;
  215. }
  216. // 새로고침 시 active 유지
  217. this.setActiveTag(link);
  218. // 새로고침 시 자동 조회
  219. this.fetchPosts({ target: link });
  220. }
  221. }
  222. document.addEventListener('DOMContentLoaded', function()
  223. {
  224. let processTags = new ProcessTags();
  225. if (processTags.tags) {
  226. processTags.tags.addEventListener('click', (e) => processTags.fetchPosts(e));
  227. }
  228. if (processTags.posts) {
  229. processTags.posts.addEventListener('click', (e) => processTags.fetchPosts(e));
  230. }
  231. if (processTags.pagination) {
  232. processTags.pagination.addEventListener('click', (e) => processTags.fetchPosts(e));
  233. }
  234. // 새로고침/직접접속 시 URL의 tag 반영
  235. processTags.initFromUrl();
  236. // 뒤로가기/앞으로가기 대응
  237. window.addEventListener('popstate', () => processTags.initFromUrl());
  238. });