fix(board): 비밀글 반응 노출 결함 수정 + 반응 버튼 UX 개선 + E2E 결함 수정

- 열람 권한 없는 비밀글에 추천/비추천을 남길 수 있던 결함을 서버(ReactionService)와
 화면(반응 영역 미노출) 양쪽에서 차단. 신고 기능과 동일한 권한 판정 기준 재사용.
- 반응 버튼 UX를 다듬어 활성 상태는 색상으로만 표시하고, 클릭 순간에만 살짝 눌리는
 촉각 피드백(active:scale-95)을 추가.
- 이슈 명세 재검토 중 describe.skip 상태였던 반응 E2E spec 에서 결함 3건 발견·수정:
 대상 게시판/게시글이 실제로 반응 불가능한 대상이었던 점, 3단계(해제) 검증이 값
 우연 일치로 거짓 통과 가능했던 점을 고치고 실제 실행으로 통과 확인.
This commit is contained in:
chym1217
2026-08-07 17:13:45 +09:00
parent 6510ce9d9c
commit 6490b339dd
13 changed files with 193 additions and 34 deletions
@@ -4,33 +4,21 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.5] - 2026-08-06
## [1.0.2] - 2026-08-06
### Added
- 관리자 게시글 작성·수정 화면의 첨부 안내 문구 일본어 번역 추가 — 게시판에 설정된 첨부 개수·용량이 일본어 로케일에서도 그대로 표시됩니다.
### Removed
- 화면에서 쓰이지 않던 첨부 안내(고정 5개·10MB)와 답변글 깊이 안내(1~5) 번역을 제거했습니다. 실제 상한과 다른 값이라 남겨 둘 이유가 없습니다.
## [1.0.4] - 2026-07-22
### Added
- 게시글 반응(추천/비추천) 기능의 안내·검증·활동 로그·관리자 설정 문구 일본어 번역 추가 — 반응 등록/변경/취소 안내, 반응 비활성화·본인 글·비활성 유형 차단 안내, 반응 사용 토글·유형 선택 설정 라벨이 일본어 로케일에서 자연스럽게 표시됩니다.
- 게시글 반응(추천/비추천) 기능의 안내·검증·활동 로그·관리자 설정 문구 일본어 번역 추가 — 반응 등록/변경/취소 안내, 반응 비활성화·본인 글·비활성 유형·열람 권한 없는 비밀글 차단 안내, 반응 사용 토글·유형 선택 설정 라벨이 일본어 로케일에서 자연스럽게 표시됩니다.
- 첨부파일 개수 상한 초과 안내 문구 일본어 번역 추가.
- 게시판 대시보드 조회 안내 문구 일본어 번역 추가.
## [1.0.3] - 2026-07-22
### Added
- 관리자 게시글 작성·수정 화면의 첨부 안내 문구 일본어 번역 추가 — 게시판에 설정된 첨부 개수·용량이 일본어 로케일에서도 그대로 표시됩니다.
- 게시판 환경설정의 조회·저장·일괄적용·캐시 초기화 안내 문구 일본어 번역 추가 (`messages.settings.*`) — 저장에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 댓글 비밀번호 확인, 댓글 목록 조회, 게시판 목록·폼 조회 실패, 비밀글 사용 불가 안내 문구의 일본어 번역 추가 — 지금까지 일본어 로케일에서 안내 문구 대신 내부 식별자가 그대로 보이던 화면들이 정상 표시됩니다.
- 게시판 설정 일괄 적용 화면의 입력 항목명과 검증 안내 문구, 신고 상태 변경 불가 안내, 권한 모드 선택 안내의 일본어 번역 추가.
## [1.0.2] - 2026-07-22
### Removed
- 화면에서 쓰이지 않던 첨부 안내(고정 5개·10MB)와 답변글 깊이 안내(1~5) 번역을 제거했습니다. 실제 상한과 다른 값이라 남겨 둘 이유가 없습니다.
### Fixed
@@ -299,5 +299,6 @@ return [
'inactive_type' => 'この掲示板では使用できないリアクションタイプです。',
'self_post' => '自分の投稿にはリアクションできません。',
'login_required' => 'ログインが必要な機能です。',
'secret_denied' => '閲覧権限がない秘密投稿には反応できません。',
],
];
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-board) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)"
},
"version": "1.0.5",
"version": "1.0.2",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-board",
+1 -1
View File
@@ -13,7 +13,7 @@
### Added
- 게시글에 추천·비추천 반응을 남길 수 있는 기능을 추가했습니다. 로그인한 회원은 게시글 상세에서 반응을 남기거나 다른 반응으로 바꾸거나 취소할 수 있고, 각 반응의 개수가 항상 함께 표시됩니다. 본인이 쓴 글에는 반응할 수 없습니다. 게시판 관리 설정에서 반응 사용 여부와 사용할 반응 유형(추천·비추천)을 게시판별로 켜고 끌 수 있으며, 새 게시판을 만들 때 적용될 기본값도 환경설정에서 지정합니다.
- 게시글에 추천·비추천 반응을 남길 수 있는 기능을 추가했습니다. 로그인한 회원은 게시글 상세에서 반응을 남기거나 다른 반응으로 바꾸거나 취소할 수 있고, 각 반응의 개수가 항상 함께 표시됩니다. 본인이 쓴 글에는 반응할 수 없으며, 열람 권한이 없어 본문을 볼 수 없는 비밀글에는 반응 버튼이 표시되지 않고 반응도 남길 수 없습니다. 게시판 관리 설정에서 반응 사용 여부와 사용할 반응 유형(추천·비추천)을 게시판별로 켜고 끌 수 있으며, 새 게시판을 만들 때 적용될 기본값도 환경설정에서 지정합니다.
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
- 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
@@ -19,7 +19,7 @@ use Illuminate\Http\JsonResponse;
class ReactionNotAllowedException extends Exception
{
/**
* @param string $reason 거절 사유 (disabled | inactive_type | self_post)
* @param string $reason 거절 사유 (disabled | inactive_type | self_post | secret_denied)
* @param string $message 사용자에게 보일 메시지
*/
public function __construct(
@@ -59,6 +59,16 @@ class ReactionNotAllowedException extends Exception
return new self('self_post', __('sirsoft-board::messages.reaction.self_post'));
}
/**
* 열람 권한이 없는 비밀글 반응 시도에 대한 예외를 생성합니다.
*
* @return self 비밀글 열람 권한 없음 사유 예외
*/
public static function secretDenied(): self
{
return new self('secret_denied', __('sirsoft-board::messages.reaction.secret_denied'));
}
/**
* 거절 사유를 반환합니다.
*
@@ -2,15 +2,16 @@
namespace Modules\Sirsoft\Board\Services;
use App\Enums\PermissionType;
use App\Extension\HookManager;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
use Modules\Sirsoft\Board\Exceptions\ReactionNotAllowedException;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
/**
* 반응 서비스
@@ -21,6 +22,8 @@ use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface
*/
class ReactionService
{
use ChecksBoardPermission;
/**
* 현재 지원하는 반응 대상 타입 (확정 10 — 게시글만, 향후 comment 확장 가능).
*/
@@ -76,6 +79,14 @@ class ReactionService
throw ReactionNotAllowedException::selfPost();
}
// 비밀글 반응 차단 — 본문 열람 권한이 없는 사용자는 반응 불가.
// 신고(PostResource::canViewSecretContent)와 동일한 판정을 재사용해,
// 본문을 못 보는 사용자가 반응만 남기는 우회를 막는다.
// 작성자 본인은 위에서 이미 차단되므로 여기서는 게시판별 비밀글 열람 권한만 본다.
if ($post->is_secret && ! $this->canReactToSecretPost($board->slug)) {
throw ReactionNotAllowedException::secretDenied();
}
// 요청 유형이 존재하는 활성 유형이면서 게시판이 켠 유형인지 확인 (확정 11)
$requestedType = $this->reactionTypeRepository->findById($reactionTypeId);
$activeCodes = $board->active_reaction_types ?? [];
@@ -150,4 +161,21 @@ class ReactionService
return $result;
}
/**
* 비밀글에 반응할 수 있는 열람 권한이 있는지 확인합니다.
*
* 반응은 사용자(User) 페이지 전용 기능이므로 게시판별 비밀글 읽기 권한
* (posts.read-secret) 또는 게시판 매니저 권한만 인정합니다. 작성자 본인은
* 호출 이전에 이미 selfPost 로 차단되므로 여기서는 고려하지 않습니다.
* `PostResource::canViewSecretContent` 의 게시판 권한 판정과 동일한 기준입니다.
*
* @param string $slug 대상 게시판 슬러그
* @return bool 비밀글 반응 가능 여부
*/
private function canReactToSecretPost(string $slug): bool
{
return $this->checkBoardPermission($slug, 'posts.read-secret', PermissionType::User)
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
}
}
@@ -134,6 +134,7 @@ return [
'disabled' => 'Reactions are disabled for this board.',
'inactive_type' => 'This reaction type is not available on this board.',
'self_post' => 'You cannot react to your own post.',
'secret_denied' => 'You cannot react to a secret post you are not allowed to view.',
'login_required' => 'You need to sign in to use this feature.',
],
@@ -134,6 +134,7 @@ return [
'disabled' => '이 게시판은 반응 기능이 비활성화되어 있습니다.',
'inactive_type' => '이 게시판에서 사용할 수 없는 반응 유형입니다.',
'self_post' => '본인 글에는 반응할 수 없습니다.',
'secret_denied' => '열람 권한이 없는 비밀글에는 반응할 수 없습니다.',
'login_required' => '로그인이 필요한 기능입니다.',
],
@@ -14,6 +14,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
* 차단, 본인 글 차단, 비로그인 차단을 종단 검증한다 (이슈 #525 §10 테스트 범위).
*
* @scenario case=guest_react_blocked
*
* @effects guest_react_returns_401
*/
class ReactionApiTest extends BoardTestCase
@@ -191,6 +192,70 @@ class ReactionApiTest extends BoardTestCase
->assertNotFound();
}
/**
* 비밀글에 열람 권한이 없는 사용자는 반응할 수 없다 (422, secret_denied).
*
* 회귀: 반응 서비스가 비밀글 열람 권한을 검사하지 않으면, 본문(content=null)을
* 못 보는 사용자가 추천/비추천만 남기는 우회가 가능하다. 신고(canViewSecretContent)와
* 동일하게 열람 권한 없는 비밀글 반응을 서버가 최종 차단해야 한다.
*
* @scenario case=secret_post_react_blocked
*
* @effects secret_post_without_permission_returns_422
*/
public function test_react_blocked_on_secret_post_without_permission(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost([
'user_id' => $author->id,
'is_secret' => true,
]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertStatus(422)
->assertJsonPath('errors.code', 'reaction_not_allowed');
// 반응 이력이 남지 않아야 한다
$this->assertDatabaseMissing('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
]);
}
/**
* 비밀글 열람 권한(posts.read-secret)이 있는 사용자는 비밀글에도 반응할 수 있다.
*
* 비밀글 가드가 열람 권한자까지 막지 않는지 확인한다 (신고 판정과 동일 기준).
*
* @scenario case=secret_post_react_allowed_with_permission
*
* @effects secret_post_with_read_secret_permission_allows_react
*/
public function test_react_allowed_on_secret_post_with_read_secret_permission(): void
{
$this->grantUserRolePermissions(['posts.read-secret']);
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost([
'user_id' => $author->id,
'is_secret' => true,
]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk()
->assertJsonPath('data.action', 'add');
$this->assertDatabaseHas('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
'reaction_type_id' => $this->likeId,
]);
}
/**
* 게시글 상세 응답의 reaction_counts 가 유형 ID 키를 보존한다 (JSON 객체, 배열 재인덱싱 금지).
*
@@ -230,6 +295,7 @@ class ReactionApiTest extends BoardTestCase
* 반응 등록/전환/해제 시 활동 로그가 기록된다 (after_react 훅 → 리스너).
*
* @scenario case=react_logs_activity
*
* @effects react_logs_add_change_remove_activity
*/
public function test_react_writes_activity_log(): void
@@ -17,8 +17,8 @@
*/
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
const SLUG = 'notice';
const POST_ID = 1;
const SLUG = 'free';
const POST_ID = 18;
const POST_PATH = `/board/${SLUG}/${POST_ID}`;
const REACT_API = `**/api/modules/sirsoft-board/boards/${SLUG}/posts/${POST_ID}/react`;
@@ -26,7 +26,7 @@ const REACT_API = `**/api/modules/sirsoft-board/boards/${SLUG}/posts/${POST_ID}/
const LIKE_ID = 1;
const DISLIKE_ID = 2;
test.describe.skip('게시판 반응 등록→전환→해제 흐름 (#525)', () => {
test.describe('게시판 반응 등록→전환→해제 흐름 (#525)', () => {
// @scenario surface=detail_register_switch_remove
// @effects register_inserts_row_and_increments_count, switch_updates_row_and_adjusts_both_counts, remove_deletes_row_and_decrements_count
test('추천 등록 → 비추천 전환 → 비추천 해제 시 버튼 하이라이트·개수가 갱신된다', async ({
@@ -66,19 +66,28 @@ test.describe.skip('게시판 반응 등록→전환→해제 흐름 (#525)', ()
const dislikeButton = page.getByRole('button', { name: /비추천|Not Recommend/ }).first();
// 1) 추천 등록 → count +1, reaction_type_id = like
// 처리 중에는 버튼이 disabled(스피너) 되므로, 응답 도착 후 스피너가 완전히 걷히고
// 카운트 텍스트가 갱신될 때까지 기다린 다음 다음 클릭을 보낸다 — 그렇지 않으면 아직
// 리렌더 중인 버튼에 클릭이 씹혀 요청이 누락된다.
// step 자체를 폴링한다(2·3단계 모두 reaction_type_id=DISLIKE_ID 로 동일하므로,
// reactBody 값만 보면 세 번째 요청이 발생하지 않아도 이전 값과 우연히 일치해 통과해버린다).
await likeButton.click();
await expect.poll(() => reactBody?.reaction_type_id, { timeout: 5_000 }).toBe(LIKE_ID);
await expect.poll(() => step, { timeout: 5_000 }).toBe(1);
expect((reactBody as Record<string, unknown> | null)?.reaction_type_id).toBe(LIKE_ID);
await expect(likeButton).toHaveText(/추천\s*1/, { timeout: 5_000 });
await expect(dislikeButton).toBeEnabled({ timeout: 5_000 });
// 2) 비추천 전환 → like -1 · dislike +1
await dislikeButton.click();
await expect.poll(() => reactBody?.reaction_type_id, { timeout: 5_000 }).toBe(DISLIKE_ID);
await expect.poll(() => step, { timeout: 5_000 }).toBe(2);
expect((reactBody as Record<string, unknown> | null)?.reaction_type_id).toBe(DISLIKE_ID);
await expect(dislikeButton).toHaveText(/비추천\s*1/, { timeout: 5_000 });
await expect(dislikeButton).toBeEnabled({ timeout: 5_000 });
// 3) 비추천 재클릭 → 해제 (같은 유형 재요청)
await dislikeButton.click();
await expect.poll(() => reactBody?.reaction_type_id, { timeout: 5_000 }).toBe(DISLIKE_ID);
// 세 번의 react 호출이 모두 발생 (등록/전환/해제)
expect(step).toBe(3);
await expect.poll(() => step, { timeout: 5_000 }).toBe(3);
expect((reactBody as Record<string, unknown> | null)?.reaction_type_id).toBe(DISLIKE_ID);
});
// @scenario surface=detail_register_switch_remove
@@ -40,6 +40,9 @@ axes:
- react_logs_activity
- react_failure_rolls_back
- react_pending_disables_button
- secret_post_react_blocked
- secret_post_react_allowed_with_permission
- reaction_area_hidden_on_inaccessible_secret_post
effects:
# 등록/전환/해제 + 카운트 증감 정합성
@@ -54,6 +57,10 @@ effects:
- inactive_type_react_returns_422
- use_reaction_off_react_returns_422
- post_not_in_board_returns_404
# 비밀글 열람 권한 가드 (신고와 동일 기준)
- secret_post_without_permission_returns_422
- secret_post_with_read_secret_permission_allows_react
- reaction_area_hidden_when_secret_content_inaccessible
# 화면 렌더 (확정 09·11)
- active_types_always_shown_even_zero_count
- reaction_area_hidden_when_no_active_types
@@ -58,6 +58,19 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
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 한다', () => {
@@ -185,4 +198,39 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
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');
});
});
@@ -940,7 +940,7 @@
"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}}",
"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"
},
@@ -955,13 +955,13 @@
},
"children": [
{
"comment": "반응 유형 버튼 — 하이라이트는 클릭 즉시 이동(_local.myReactionTypeId override, 없으면 서버값), 개수는 react API 응답의 reaction_counts 로 갱신(_local.reactionCounts override). 처리 중(_local.reactingTypeId)은 짧은 로딩(스피너+비활성). 실패 시 override 를 클릭 전 값으로 롤백. 본인 글은 항상 비활성.",
"comment": "반응 유형 버튼 — 하이라이트는 클릭 즉시 이동(_local.myReactionTypeId override, 없으면 서버값), 개수는 react API 응답의 reaction_counts 로 갱신(_local.reactionCounts override). 처리 중(_local.reactingTypeId)은 짧은 로딩(스피너+비활성). 실패 시 override 를 클릭 전 값으로 롤백. 본인 글은 항상 비활성. 활성 상태는 배경색+테두리+하단 밑줄로 표시(상시 확대는 제거 — 부자연스럽다는 PO 피드백). 클릭하는 순간에만 active:scale-95 로 살짝 눌리는 촉각 피드백을 주며, 손을 떼면 즉시 원래 크기로 복귀(순수 CSS :active 의사클래스, 상태 관리 불필요).",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{post?.data?.is_author === true || (_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 transition-colors' : '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 transition-colors flex items-center gap-1.5'}}{{(post?.data?.is_author || (_local?.reactingTypeId ?? null) !== null) ? ' cursor-not-allowed opacity-60' : ' cursor-pointer'}}"
"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": [
{