tag.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  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. // 빈/미지정 태그를 '' 로 정규화 (undefined/null 이 "undefined" 문자열로 전송되는 것 방지)
  59. const t = (tag || '').trim();
  60. page = parseInt(page, 10) || 1;
  61. const params = new URLSearchParams({
  62. name: t,
  63. page: page
  64. });
  65. this.updateQueryString(t, page);
  66. const nav = this.findLinkByTag(t);
  67. if (nav) {
  68. this.setActiveTag(nav);
  69. } else {
  70. // 전체 모드: 활성 태그 표시 해제
  71. this.tags.querySelectorAll('.tag-nav-link.active').forEach(link => link.classList.remove('active'));
  72. }
  73. this.posts.innerHTML = ' \
  74. <div class="text-center my-4"> \
  75. <div class="spinner-border" role="status"> \
  76. <span class="visually-hidden">Loading...</span> \
  77. </div> \
  78. </div> \
  79. ';
  80. try {
  81. if (this.loading) {
  82. return;
  83. }
  84. this.loading = true;
  85. const res = await fetch(`${BASE_URL}/tag/posts?${params.toString()}`, {
  86. headers: {
  87. 'X-Requested-With': 'XMLHttpRequest',
  88. 'Accept': 'application/json',
  89. }
  90. });
  91. if (!res.ok) {
  92. throw new Error();
  93. }
  94. const data = await res.json().then(r => r.data);
  95. this.renderPosts(data);
  96. this.renderPagination(data);
  97. } catch (error) {
  98. this.posts.innerHTML = `
  99. <p>
  100. 조회 중 오류가 발생했습니다.<br/>
  101. 잠시 후 다시 시도해주세요.
  102. </p>
  103. `;
  104. } finally {
  105. this.loading = false;
  106. }
  107. }
  108. async fetchPosts(e)
  109. {
  110. const target = e.target.closest?.('.tag-nav-link, .tag-sub-link, .page-link');
  111. if (!target) {
  112. return;
  113. }
  114. if (target.classList.contains('active') && target.classList.contains('tag-nav-link')) {
  115. // 활성 태그 재클릭 = 선택 해제 → 태그가 걸린 전체 게시글 조회
  116. target.classList.remove('active');
  117. return this.loadPosts('', 1);
  118. }
  119. let tag = (target.dataset.name || target.textContent || '').trim();
  120. let page = 1;
  121. if (target.classList.contains('page-link')) {
  122. e.preventDefault();
  123. tag = this.getTagFromQuery();
  124. page = parseInt(target.dataset.page || '1', 10) || 1;
  125. }
  126. return this.loadPosts(tag, page);
  127. }
  128. renderPosts(res)
  129. {
  130. if (!res || res.rows <= 0) {
  131. this.posts.innerHTML = '<p class="p-5 text-center">관련 게시물이 없습니다.</p>';
  132. return;
  133. }
  134. let html = "";
  135. res.list.forEach(function(item) {
  136. html += `
  137. <li class="list-group-item">
  138. <div class="row align-items-center">
  139. ${item.thumbnail ? `
  140. <div class="col col-lg-2">
  141. <a href="${BASE_URL}/board/photo/${item.id}" target="_blank" rel="noopener">
  142. <img src="${item.thumbnail}" alt="${item.subject}" class="img-fluid img-thumbnail"/>
  143. </a>
  144. </div>
  145. ` : ''}
  146. <div class="${item.thumbnail ? `col col-lg-10` : 'col'}">
  147. <dl class="mb-0">
  148. <dt>
  149. <div class="row">
  150. <div class="col">
  151. <h4>
  152. <a href="${BASE_URL}/board/photo/${item.id}" target="_blank" rel="noopener">
  153. ${item.subject}
  154. </a>
  155. </h4>
  156. </div>
  157. <div class="col-auto">
  158. <small>${item.createdAt}</small>
  159. </div>
  160. </div>
  161. </dt>
  162. <dd>
  163. <blockquote class="blockquote">
  164. ${item.content}
  165. </blockquote>
  166. </dd>
  167. <dd>${item.userName}</dd>
  168. <dd class="mb-0">
  169. ${item.tags.map(tag => `
  170. <span class="tag-sub-link" data-name="${tag.toString().replace(/"/g, '&quot;')}">
  171. #${tag}
  172. </span>
  173. `).join(',')}
  174. </dd>
  175. </dl>
  176. </div>
  177. </div>
  178. </li>
  179. `;
  180. });
  181. this.posts.innerHTML = html;
  182. }
  183. renderPagination(res)
  184. {
  185. const total = parseInt(res.total || '0', 10) || 0;
  186. const page = parseInt(res.page || '1', 10) || 1;
  187. const perPage = parseInt(res.perPage || '10', 10) || 10;
  188. const last = Math.ceil(total / perPage);
  189. if (last <= 1) {
  190. return;
  191. }
  192. const make = (p, label, disabled = false, active = false) => `
  193. <li class="page-item ${disabled ? 'disabled' : ''} ${active ? 'active' : ''}">
  194. <a class="page-link" href="#" data-page="${p}">${label}</a>
  195. </li>
  196. `;
  197. let html = `<hr/><nav><ul class="pagination">`;
  198. html += make(page - 1, '‹', page <= 1);
  199. // 간단히 1~last 다 찍으면 페이지 많을 때 별로라서 window 방식(최대 7개)
  200. const windowSize = 7;
  201. let start = Math.max(1, page - Math.floor(windowSize / 2));
  202. let end = Math.min(last, start + windowSize - 1);
  203. start = Math.max(1, end - windowSize + 1);
  204. for (let p = start; p <= end; p++) {
  205. html += make(p, String(p), false, p === page);
  206. }
  207. html += make(page + 1, '›', page >= last);
  208. html += `</ul></nav>`;
  209. this.pagination.innerHTML = html;
  210. }
  211. initFromUrl()
  212. {
  213. const tag = this.getTagFromQuery();
  214. const page = this.getPageFromQuery();
  215. // 태그가 목록에 존재하면 해당 태그로, 아니면(미선택/없는 태그) 태그 걸린 전체 게시글 조회
  216. const link = this.findLinkByTag(tag);
  217. this.loadPosts(link ? tag : '', page);
  218. }
  219. }
  220. document.addEventListener('DOMContentLoaded', function()
  221. {
  222. let processTags = new ProcessTags();
  223. if (processTags.tags) {
  224. processTags.tags.addEventListener('click', (e) => processTags.fetchPosts(e));
  225. }
  226. if (processTags.posts) {
  227. processTags.posts.addEventListener('click', (e) => processTags.fetchPosts(e));
  228. }
  229. if (processTags.pagination) {
  230. processTags.pagination.addEventListener('click', (e) => processTags.fetchPosts(e));
  231. }
  232. // 새로고침/직접접속 시 URL의 tag 반영
  233. processTags.initFromUrl();
  234. // 뒤로가기/앞으로가기 대응
  235. window.addEventListener('popstate', () => processTags.initFromUrl());
  236. });