Content.tsx 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. 'use client';
  2. import './style.scss';
  3. import '@/app/styles/community.scss';
  4. import { useEffect, useRef } from 'react';
  5. import BoardMeta from '@/types/forum/boardMeta';
  6. import BoardManager from '@/types/forum/boardManager';
  7. import { checkPermission } from '@/lib/utils/permission';
  8. type Props = {
  9. boardMeta: BoardMeta;
  10. content: string;
  11. boardManagers?: BoardManager[];
  12. }
  13. export default function Content({ boardMeta, content, boardManagers }: Props)
  14. {
  15. const articleRef = useRef<HTMLElement>(null);
  16. const allowTargetBlank = boardMeta.view.allowContentLinkTargetBlank;
  17. // $종목 태그 렌더 — 본문 텍스트의 `$005930(삼성전자)` 토큰을 /stock 링크 배지로 치환
  18. useEffect(() => {
  19. const el = articleRef.current;
  20. if (!el) {
  21. return;
  22. }
  23. const pattern = /\$(\d{6})(?!\d)(?:\(([^)]{0,20})\))?/g;
  24. const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
  25. acceptNode(node) {
  26. if (!node.nodeValue || !node.nodeValue.includes('$')) {
  27. return NodeFilter.FILTER_REJECT;
  28. }
  29. let parent = node.parentElement;
  30. while (parent && parent !== el) {
  31. if (parent.classList && parent.classList.contains('stock-tag')) {
  32. return NodeFilter.FILTER_REJECT;
  33. }
  34. parent = parent.parentElement;
  35. }
  36. return NodeFilter.FILTER_ACCEPT;
  37. }
  38. });
  39. const targets: Text[] = [];
  40. let cursor = walker.nextNode();
  41. while (cursor) {
  42. targets.push(cursor as Text);
  43. cursor = walker.nextNode();
  44. }
  45. for (const textNode of targets) {
  46. const text = textNode.nodeValue ?? '';
  47. pattern.lastIndex = 0;
  48. if (!pattern.test(text)) {
  49. continue;
  50. }
  51. pattern.lastIndex = 0;
  52. const frag = document.createDocumentFragment();
  53. let lastIndex = 0;
  54. let match: RegExpExecArray|null;
  55. while ((match = pattern.exec(text)) !== null) {
  56. const [full, code, name] = match;
  57. if (match.index > lastIndex) {
  58. frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
  59. }
  60. const anchor = document.createElement('a');
  61. anchor.href = `/stock/${code}`;
  62. anchor.className = 'stock-tag';
  63. const nameSpan = document.createElement('span');
  64. nameSpan.textContent = name ? name : code;
  65. anchor.appendChild(nameSpan);
  66. const codeSpan = document.createElement('span');
  67. codeSpan.className = 'stock-tag__code';
  68. codeSpan.textContent = code;
  69. anchor.appendChild(codeSpan);
  70. frag.appendChild(anchor);
  71. lastIndex = match.index + full.length;
  72. }
  73. if (lastIndex < text.length) {
  74. frag.appendChild(document.createTextNode(text.slice(lastIndex)));
  75. }
  76. textNode.parentNode?.replaceChild(frag, textNode);
  77. }
  78. }, [content]);
  79. useEffect(() => {
  80. const el = articleRef.current;
  81. if (!el) {
  82. return;
  83. }
  84. const handler = (e: MouseEvent) => {
  85. const target = e.target as HTMLElement;
  86. // 파일 다운로드
  87. const file = target.closest('section.file-embed') as HTMLElement;
  88. if (file && file.dataset.uuid) {
  89. // 파일 다운로드 권한 체크
  90. if (boardManagers) {
  91. const stored = localStorage.getItem('member');
  92. const member = stored ? JSON.parse(stored) : null;
  93. if (!checkPermission(boardMeta, boardManagers, member).canDownloadFile) {
  94. alert('파일을 다운로드할 수 있는 권한이 없습니다.');
  95. return;
  96. }
  97. }
  98. window.location.href = `/api/forum/post/file/${file.dataset.uuid}`;
  99. }
  100. // 링크 클릭
  101. const anchor = target.closest('a[data-uuid]') as HTMLAnchorElement;
  102. if (anchor && anchor.dataset.uuid) {
  103. e.preventDefault();
  104. window.open(`/api/forum/post/link/${anchor.dataset.uuid}`, allowTargetBlank ? '_blank' : '_self');
  105. }
  106. }
  107. el.addEventListener('click', handler);
  108. return () => {
  109. el.removeEventListener('click', handler);
  110. }
  111. }, [allowTargetBlank, boardMeta, boardManagers]);
  112. return (
  113. <article ref={articleRef} dangerouslySetInnerHTML={{ __html: content }} className='whitespace-normal break-words'></article>
  114. );
  115. }