chore(board,basic,core,admin_basic): 게시판 반응(추천/비추천) 기능 및 비즈뿌리오 메시징 플러그인 제거

게시판 게시글 반응 기능과 비즈뿌리오(카카오 알림톡·문자) 메시징 플러그인을 제거한다.

게시판 반응
- 마이그레이션 5종(반응 유형·반응·게시판 사용여부·활성 유형·게시글 카운트), 시더,
 모델·저장소·서비스·컨트롤러·검증 규칙·예외·API 리소스 제거
- 관리자 게시판 폼/환경설정의 반응 사용 토글과 유형 선택 블록, 반응 유형 데이터소스 제거
- 사용자 게시글 상세의 반응 버튼 영역 제거 (반응 도입 이전 상태로 복원)
- 반응 관련 다국어 키(ko/en/ja), API 문서, 시나리오 매니페스트, 단위·기능·E2E 테스트 제거

비즈뿌리오 메시징 플러그인
- 플러그인 본체와 일본어 번들 언어팩 제거
- 코어 문서·빌드 스크립트·검사 baseline, 게시판·이커머스 CHANGELOG 에 남아 있던 참조 정리

존치 항목
- 게시판·이커머스 알림 설정 행 하단의 확장 슬롯(notification_definition_row_footer)은
 코어 알림 설정 화면과 동일한 범용 확장점이라 유지한다
- 사용자 템플릿의 PostReactions 컴포넌트는 반응 기능 도입 이전부터 존재하던 자산이라 유지한다
- 태그 입력 드롭다운이 하단 고정 버튼에 가려지던 수정은 반응 기능과 무관해 유지한다

부수 정정
- 라우팅 판정 diff 스냅샷 총량을 실측값으로 갱신한다. 기록값 463 은 제거 이전 시점에도
 실제(471)와 어긋나 있었고, 이번 제거 후 실측은 469 다.
This commit is contained in:
HeuJung
2026-08-10 16:28:03 +09:00
parent f70c41c9ee
commit d3a62a7e04
232 changed files with 25 additions and 26042 deletions
@@ -12,7 +12,6 @@
- 주문서의 무통장입금 영역에 현금영수증 신청 폼이 표시될 수 있도록 확장 영역을 마련했습니다. 신청 폼 자체는 쇼핑몰 기능이 제공합니다.
- 주문 상세의 결제 정보 영역에 현금영수증 발급 내역이 표시될 수 있도록 확장 영역을 마련했습니다.
- 주문서에서 결제 플러그인이 등록한 결제수단(예: "가상계좌 (토스페이먼츠)")을 선택해 주문할 수 있습니다. 선택한 결제수단은 주문 시 대응하는 기본 결제수단으로 전송되어, 결제창 호출과 이후 주문 처리가 정상적으로 이어집니다.
- 게시글 상세 화면에 추천·비추천 반응 버튼을 추가했습니다. 로그인한 회원은 버튼을 눌러 반응을 남기거나, 다른 반응으로 바꾸거나, 같은 버튼을 다시 눌러 취소할 수 있으며 각 반응의 개수가 함께 표시됩니다. 버튼을 누르면 즉시 반응 표시가 반영되고, 처리 중에는 잠깐 로딩이 표시되며, 처리에 실패하면 원래 상태로 되돌리고 안내합니다. 반응 버튼은 게시글 하단의 수정·삭제 버튼과 같은 줄에 배치됩니다. 본인이 쓴 글에서 버튼을 누르면 반응할 수 없다는 안내가 표시되고, 로그인하지 않은 경우 안내 후 로그인 화면으로 이동합니다. (게시판에서 반응 기능을 켠 경우에만 표시됩니다.)
- 회원가입 화면에 휴대폰번호·전화번호 입력란을 추가했습니다. 두 항목 모두 선택 사항입니다.
### Changed
@@ -1,243 +0,0 @@
/**
* @file board-reaction-buttons.test.tsx
* @description 게시글 상세 반응(추천/비추천) 버튼 레이아웃 회귀 (이슈 #525)
*
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (board 파티션 테스트 관례 — show.json 은 partial).
* 고정 대상:
* - 반응 영역은 use_reaction && reaction_type_options.length > 0 일 때만 노출 (확정 11)
* - reaction_type_options 를 iteration 으로 순회 (확정 02·유형 동적 렌더)
* - 유형별 개수는 reaction_counts[유형 ID] 를 바인딩, 없으면 0 (확정 09)
* - 본인 글이면 버튼 disabled (확정 08)
* - 클릭 시 비로그인은 안내+로그인 유도, 로그인 사용자는 react API 호출 후 post 재조회 (확정 03·07)
*/
import { describe, it, expect } from 'vitest';
import basicShow from '../../layouts/partials/board/types/basic/show.json';
type Node = Record<string, unknown> & { children?: unknown };
function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] {
const result: Node[] = [];
const walk = (cur: unknown): void => {
if (Array.isArray(cur)) {
cur.forEach(walk);
return;
}
if (cur && typeof cur === 'object') {
const obj = cur as Node;
if (predicate(obj)) {
result.push(obj);
}
Object.values(obj).forEach(walk);
}
};
walk(node);
return result;
}
function findByComment(root: unknown, commentText: string): Node[] {
return collectNodes(root, (n) => typeof n.comment === 'string' && (n.comment as string).includes(commentText));
}
describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', () => {
// @scenario case=detail_hidden_when_no_active_types
// @effects reaction_area_hidden_when_no_active_types
it('반응 영역은 use_reaction + reaction_type_options 존재 시에만 노출된다 (확정 11)', () => {
// 이 if 는 use_reaction 과 reaction_type_options 둘 다에 의존하므로
// reaction off / 유형 0개 두 케이스 모두 이 조건으로 미노출된다.
// @scenario case=detail_hidden_when_use_reaction_off
// @effects reaction_area_hidden_when_use_reaction_off
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
expect(areas.length).toBe(1);
const area = areas[0];
expect(typeof area.if).toBe('string');
expect(area.if as string).toContain('use_reaction');
expect(area.if as string).toContain('reaction_type_options');
expect(area.if as string).toContain('length > 0');
});
// @scenario case=reaction_area_hidden_on_inaccessible_secret_post
// @effects reaction_area_hidden_when_secret_content_inaccessible
it('비밀글이면서 본문 열람 권한이 없으면(content=null) 반응 영역이 미노출된다 (신고와 동일 가드)', () => {
// 비밀글 본문을 못 보는 사용자가 반응만 남기는 우회를 막는다.
// 신고 버튼과 동일한 (!is_secret || content !== null) 가드를 반응 영역 if 에도 둔다.
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
expect(areas.length).toBe(1);
const cond = areas[0].if as string;
expect(cond).toContain('is_secret');
expect(cond).toContain('content !== null');
});
// @scenario case=detail_shows_active_types_even_zero
// @effects active_types_always_shown_even_zero_count
it('반응 유형을 reaction_type_options 로 iteration 한다', () => {
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
const iterations = collectNodes(areas[0], (n) => n.iteration !== undefined);
expect(iterations.length).toBeGreaterThan(0);
const iter = iterations[0].iteration as Record<string, unknown>;
expect(iter.source as string).toContain('reaction_type_options');
expect(iter.item_var).toBe('reactionType');
});
// @scenario case=author_buttons_disabled
// @effects author_sees_buttons_disabled, active_types_always_shown_even_zero_count, my_reaction_type_highlighted
it('유형별 버튼은 처리 중이면 disabled, 본인 글이면 시각적 비활성 톤 + 클릭 시 안내(disabled 미부여)로 개수를 바인딩한다 (확정 08·09)', () => {
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
expect(buttons.length).toBeGreaterThan(0);
const btn = buttons[0];
const props = btn.props as Record<string, unknown>;
expect(props.type).toBe('button');
// disabled 는 처리 중(reactingTypeId)에만 건다 — disabled 버튼은 클릭 이벤트가
// 발생하지 않아 본인 글 안내 토스트를 띄울 수 없으므로, 본인 글은 disabled 대신
// className 의 시각적 톤 + switch 핸들러의 self_post 분기로 처리한다.
expect(String(props.disabled)).toContain('reactingTypeId');
expect(String(props.disabled)).not.toContain('is_author');
expect(String(props.className)).toContain('is_author');
expect(String(props['aria-pressed'])).toBeTruthy();
expect(String(props['aria-disabled'])).toContain('reactingTypeId');
// 개수 바인딩 (reaction_counts[유형 ID] ?? 0)
const counts = collectNodes(btn, (n) =>
typeof n.text === 'string' && (n.text as string).includes('reaction_counts')
);
expect(counts.length).toBe(1);
expect(counts[0].text as string).toContain('reactionType.id');
expect(counts[0].text as string).toContain('?? 0');
});
it('클릭 액션은 비로그인/로그인 분기 — 로그인은 낙관 하이라이트+react API, 비로그인은 안내+로그인 (확정 03·07)', () => {
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
const actions = (buttons[0].actions as Node[]) ?? [];
expect(actions.length).toBeGreaterThan(0);
const action = actions[0];
expect(action.handler).toBe('switch');
const cases = action.cases as Record<string, Node>;
expect(cases.logged_in).toBeDefined();
expect(cases.not_logged_in).toBeDefined();
// 로그인 사용자: sequence(낙관 하이라이트 setState → react apiCall)
const loggedIn = cases.logged_in;
expect(loggedIn.handler).toBe('sequence');
const seq = loggedIn.actions as Node[];
// 첫 액션: 클릭 즉시 하이라이트 낙관 이동 + 처리 중 표시
const firstSetState = seq[0];
expect(firstSetState.handler).toBe('setState');
const firstParams = firstSetState.params as Record<string, unknown>;
expect(firstParams.reactingTypeId).toBeDefined();
expect(firstParams.myReactionTypeId).toBeDefined();
// 개수는 낙관적으로 건드리지 않는다 (reactionCounts 는 응답에서만)
expect(firstParams.reactionCounts).toBeUndefined();
// apiCall: react + 응답값으로 확정(refetch 없음)
const apiCall = seq.find((a) => a.handler === 'apiCall')!;
expect(apiCall).toBeDefined();
expect(apiCall.target as string).toContain('/react');
const onSuccess = apiCall.onSuccess as Node[];
// 성공: refetch 대신 응답값(reaction_counts/my_reaction_type_id)으로 setState
expect(onSuccess.some((a) => a.handler === 'refetchDataSource')).toBe(false);
const successSet = onSuccess.find((a) => a.handler === 'setState')!;
const successParams = successSet.params as Record<string, unknown>;
expect(String(successParams.reactionCounts)).toContain('response.data.reaction_counts');
expect(String(successParams.myReactionTypeId)).toContain('response.data.my_reaction_type_id');
// 비로그인: 안내 후 로그인 유도 (redirect 파라미터 전달)
const guest = cases.not_logged_in;
const guestActions = guest.actions as Node[];
expect(guestActions.some((a) => a.handler === 'toast')).toBe(true);
const navAction = guestActions.find((a) => a.handler === 'navigate');
expect(navAction).toBeDefined();
const navParams = navAction!.params as Record<string, unknown>;
expect(navParams.path).toBe('/login');
expect((navParams.query as Record<string, unknown>).redirect).toContain('/board/');
});
// @scenario case=react_failure_rolls_back
// @effects reaction_failure_rolls_back_optimistic_state
it('실패 시 낙관 상태를 롤백하고 안내 토스트를 발화한다 (onError)', () => {
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
const action = (buttons[0].actions as Node[])[0];
const apiCall = ((action.cases as Record<string, Node>).logged_in.actions as Node[])
.find((a) => a.handler === 'apiCall')!;
const onError = apiCall.onError as Node[];
expect(onError.length).toBeGreaterThan(0);
// 롤백: override 를 초기값으로 되돌리는 setState (reactionApplied=false, override 클리어)
const rollback = onError.find((a) => a.handler === 'setState')!;
expect(rollback).toBeDefined();
const rp = rollback.params as Record<string, unknown>;
expect(rp.reactionApplied).toBe(false);
expect(rp.myReactionTypeId).toBe(null);
expect(rp.reactionCounts).toBe(null);
expect(rp.reactingTypeId).toBe(null);
// 안내 토스트 (하드코딩 아닌 $t: 키)
const toast = onError.find((a) => a.handler === 'toast')!;
expect(toast).toBeDefined();
const tp = toast.params as Record<string, unknown>;
expect(tp.type).toBe('error');
expect(String(tp.message)).toContain('$t:');
});
// @scenario case=react_pending_disables_button
// @effects reaction_pending_disables_button
it('처리 중(reactingTypeId)에는 버튼이 비활성이고 스피너가 표시된다 (중복 클릭 차단)', () => {
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
const btn = buttons[0];
const props = btn.props as Record<string, unknown>;
// disabled 가 reactingTypeId 에 의존 (처리 중 비활성)
expect(String(props.disabled)).toContain('reactingTypeId');
// 스피너: 처리 중인 유형이면 fa-spinner + spin
const icon = collectNodes(btn, (n) => n.name === 'Icon')[0];
const iconProps = icon.props as Record<string, unknown>;
expect(String(iconProps.name)).toContain('fa-spinner');
expect(String(iconProps.spin)).toContain('reactingTypeId');
});
// @scenario case=reaction_active_button_color_only_highlight
// @effects reaction_active_button_color_highlight, reaction_click_moment_tactile_pulse
it('활성 상태는 배경·테두리 색상으로만 표시하고, 클릭 순간에는 두 분기 모두 active:scale-95 로 살짝 눌린다 (상시 확대 제거)', () => {
const areas = findByComment(basicShow, '반응(추천/비추천) 영역 — 반응 사용 on');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');
const btn = buttons[0];
const props = btn.props as Record<string, unknown>;
const className = String(props.className);
// 표현식 전체를 감싸는 최상위 삼항 " ? '...' : '...' " 만 분리 —
// 조건절 내 옵셔널 체이닝(?.)과 혼동하지 않도록 "? '" 패턴으로 삼항 시작점을 찾는다
const ternaryStart = className.indexOf("? '") + 1;
const ternaryBody = className.slice(ternaryStart + 1);
const [activeBranch, inactiveBranch] = ternaryBody.split(" : '");
// 활성(참) 분기: 색상만으로 하이라이트, 상시 확대(scale-105)·그림자(shadow-md) 없음
expect(activeBranch).toContain('bg-blue-50');
expect(activeBranch).toContain('border-blue-500');
expect(activeBranch).not.toContain('scale-105');
expect(activeBranch).not.toContain('shadow-md');
// 비활성(거짓) 분기도 상시 축소(scale-95 상태 유지)·불투명도 저하 없음
expect(inactiveBranch).not.toContain('opacity-90');
// 두 분기 모두 클릭 순간(:active 의사클래스)에만 살짝 눌리는 순수 CSS 펄스
expect(activeBranch).toContain('active:scale-95');
expect(inactiveBranch).toContain('active:scale-95');
// transform 전환은 부드럽게 (transition-transform + duration-150)
expect(activeBranch).toContain('transition-transform');
expect(activeBranch).toContain('duration-150');
expect(inactiveBranch).toContain('transition-transform');
expect(inactiveBranch).toContain('duration-150');
});
});
@@ -141,10 +141,7 @@
"laugh": "Funny",
"agree": "Agree",
"thanks": "Thanks",
"wow": "Wow",
"login_required": "You need to sign in to use this feature.",
"failed": "Failed to process the reaction. Please try again.",
"self_post_denied": "You cannot react to your own post."
"wow": "Wow"
},
"attachments": {
"title": "Attachments",
@@ -141,10 +141,7 @@
"laugh": "웃겨요",
"agree": "동의해요",
"thanks": "감사해요",
"wow": "놀라워요",
"login_required": "로그인이 필요한 기능입니다",
"failed": "반응 처리에 실패했습니다. 잠시 후 다시 시도해주세요.",
"self_post_denied": "본인 글에는 반응할 수 없습니다."
"wow": "놀라워요"
},
"attachments": {
"title": "첨부파일",
@@ -190,7 +190,7 @@
"name": "Span",
"if": "{{post?.data?.is_new}}",
"props": {
"className": "px-2 py-1 text-xs font-bold bg-red-500 dark:bg-red-600 text-white rounded flex-shrink-0"
"className": "px-2 py-1 text-xs font-bold bg-red-500 text-white rounded flex-shrink-0"
},
"text": "N"
}
@@ -928,178 +928,12 @@
]
},
{
"comment": "게시글 하단 바 — 반응(추천/비추천) 영역과 액션 버튼(답변/신고/수정/삭제)을 한 줄에 좌우로 배치. 반응은 왼쪽, 액션은 오른쪽. 둘 중 하나만 표시될 때도 justify-between 으로 정렬 유지. 둘 다 없으면 바 자체 미노출.",
"type": "basic",
"name": "Div",
"if": "{{(post?.data?.board?.use_reaction && (post?.data?.board?.reaction_type_options ?? []).length > 0) || post?.data?.is_guest_post || post?.data?.is_author || post?.data?.abilities?.can_manage || post?.data?.board?.use_reply || (_global.currentUser?.uuid && post?.data?.board?.use_report)}}",
"props": {
"className": "flex flex-wrap items-center justify-between gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"comment": "반응(추천/비추천) 영역 — 반응 사용 on + 게시판이 켠 유형이 1개 이상일 때만 노출(확정 11). 활성 유형을 iteration 으로 순회해 유형별 버튼(아이콘+라벨+개수, 개수 0도 항상 노출, 확정 09). 본인 글은 버튼/개수는 노출하되 클릭 불가(확정 08). 비로그인은 안내 후 로그인 유도, 로그인 사용자는 같은 유형 재클릭=해제·다른 유형=전환.",
"type": "basic",
"name": "Div",
"if": "{{post?.data?.board?.use_reaction && (post?.data?.board?.reaction_type_options ?? []).length > 0 && (!post?.data?.is_secret || post?.data?.content !== null)}}",
"props": {
"className": "flex flex-wrap items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{post?.data?.board?.reaction_type_options ?? []}}",
"item_var": "reactionType",
"index_var": "reactionIdx"
},
"children": [
{
"comment": "반응 유형 버튼 — 하이라이트는 클릭 즉시 이동(_local.myReactionTypeId override, 없으면 서버값), 개수는 react API 응답의 reaction_counts 로 갱신(_local.reactionCounts override). 처리 중(_local.reactingTypeId)은 짧은 로딩(스피너+비활성, 실제 disabled). 본인 글은 시각적으로만 비활성 톤이며 disabled 는 걸지 않는다 — disabled 버튼은 클릭 이벤트 자체가 발생하지 않아 안내 토스트를 띄울 수 없으므로, 클릭은 받고 switch 분기에서 토스트로 안내한다. 실패 시 override 를 클릭 전 값으로 롤백. 활성 상태는 배경색+테두리+하단 밑줄로 표시(상시 확대는 제거 — 부자연스럽다는 PO 피드백). 클릭하는 순간에만 active:scale-95 로 살짝 눌리는 촉각 피드백을 주며, 손을 떼면 즉시 원래 크기로 복귀(순수 CSS :active 의사클래스, 상태 관리 불필요).",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{(_local?.reactingTypeId ?? null) !== null}}",
"aria-pressed": "{{(_local?.reactionApplied ? _local?.myReactionTypeId : post?.data?.my_reaction_type_id) === reactionType.id}}",
"aria-disabled": "{{(_local?.reactingTypeId ?? null) !== null}}",
"className": "{{(_local?.reactionApplied ? _local?.myReactionTypeId : post?.data?.my_reaction_type_id) === reactionType.id ? 'px-4 py-2 text-sm border border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg flex items-center gap-1.5 font-medium active:scale-95 transition-transform duration-150 ease-out' : 'px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-1.5 active:scale-95 transition-transform duration-150 ease-out'}}{{(post?.data?.is_author || (_local?.reactingTypeId ?? null) !== null) ? ' cursor-not-allowed opacity-60' : ' cursor-pointer'}}"
},
"actions": [
{
"type": "click",
"handler": "switch",
"params": {
"value": "{{post?.data?.is_author ? 'self_post' : (_global.currentUser?.uuid ? 'logged_in' : 'not_logged_in')}}"
},
"cases": {
"self_post": {
"handler": "toast",
"params": {
"type": "info",
"message": "$t:board.reaction.self_post_denied"
}
},
"logged_in": {
"handler": "sequence",
"actions": [
{
"comment": "클릭 즉시 — 하이라이트를 눌린 유형으로 낙관 이동(같은 유형 재클릭이면 해제 예측), 처리 중 표시. 개수는 건드리지 않고 응답값으로만 확정.",
"handler": "setState",
"params": {
"target": "local",
"reactingTypeId": "{{reactionType.id}}",
"reactionApplied": true,
"myReactionTypeId": "{{(_local?.reactionApplied ? _local?.myReactionTypeId : post?.data?.my_reaction_type_id) === reactionType.id ? null : reactionType.id}}"
}
},
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-board/boards/{{route.slug}}/posts/{{route.id}}/react",
"auth_mode": "required",
"params": {
"method": "POST",
"body": {
"reaction_type_id": "{{reactionType.id}}"
}
},
"onSuccess": [
{
"comment": "서버 확정값으로 하이라이트·개수 갱신 (refetch 없이 응답값만). 응답만 오면 개수는 100% 정확.",
"handler": "setState",
"params": {
"target": "local",
"reactingTypeId": null,
"reactionApplied": true,
"myReactionTypeId": "{{response.data.my_reaction_type_id ?? null}}",
"reactionCounts": "{{response.data.reaction_counts ?? {}}}"
}
}
],
"onError": [
{
"comment": "실패 롤백 — override 를 서버 초기값(post.data)으로 되돌리고 로딩 해제",
"handler": "setState",
"params": {
"target": "local",
"reactingTypeId": null,
"reactionApplied": false,
"myReactionTypeId": null,
"reactionCounts": null
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:board.reaction.failed"
}
}
]
}
]
},
"not_logged_in": {
"handler": "sequence",
"actions": [
{
"handler": "toast",
"params": {
"type": "info",
"message": "$t:board.reaction.login_required"
}
},
{
"handler": "navigate",
"params": {
"path": "/login",
"query": {
"redirect": "/board/{{route.slug}}/{{route.id}}"
}
}
}
]
}
}
}
],
"children": [
{
"comment": "처리 중인 유형은 스피너, 그 외엔 유형 아이콘",
"type": "basic",
"name": "Icon",
"props": {
"name": "{{(_local?.reactingTypeId ?? null) === reactionType.id ? 'fa-spinner' : (reactionType.icon_name ?? 'fa-thumbs-up')}}",
"spin": "{{(_local?.reactingTypeId ?? null) === reactionType.id}}",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{reactionType.name ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "tabular-nums"
},
"text": "{{(_local?.reactionCounts ?? post?.data?.reaction_counts)?.[reactionType.id] ?? 0}}"
}
]
}
]
}
]
},
{
"comment": "액션 버튼 영역 (답변, 신고, 수정, 삭제) - 비회원 글은 항상 표시 (수정/삭제용). 하단 바 오른쪽에 배치. 반응 영역이 없으면 justify-between 이 이 그룹만 오른쪽으로 민다.",
"comment": "게시글 액션 버튼 영역 (답변, 신고, 수정, 삭제) - 비회원 글은 항상 표시 (수정/삭제용)",
"type": "basic",
"name": "Div",
"if": "{{post?.data?.is_guest_post || post?.data?.is_author || post?.data?.abilities?.can_manage || post?.data?.board?.use_reply || (_global.currentUser?.uuid && post?.data?.board?.use_report)}}",
"props": {
"className": "flex items-center justify-end gap-2 ml-auto"
"className": "flex justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
@@ -1309,8 +1143,6 @@
}
]
}
]
}
]
},
{