diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md index c6d900f1..bb488438 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md @@ -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 diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php index 1cc34f62..b1bea24c 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php @@ -299,5 +299,6 @@ return [ 'inactive_type' => 'この掲示板では使用できないリアクションタイプです。', 'self_post' => '自分の投稿にはリアクションできません。', 'login_required' => 'ログインが必要な機能です。', + 'secret_denied' => '閲覧権限がない秘密投稿には反応できません。', ], ]; diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json index 4dd9c4b7..0b311854 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json @@ -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", diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 86218669..66ad86dc 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -13,7 +13,7 @@ ### Added -- 게시글에 추천·비추천 반응을 남길 수 있는 기능을 추가했습니다. 로그인한 회원은 게시글 상세에서 반응을 남기거나 다른 반응으로 바꾸거나 취소할 수 있고, 각 반응의 개수가 항상 함께 표시됩니다. 본인이 쓴 글에는 반응할 수 없습니다. 게시판 관리 설정에서 반응 사용 여부와 사용할 반응 유형(추천·비추천)을 게시판별로 켜고 끌 수 있으며, 새 게시판을 만들 때 적용될 기본값도 환경설정에서 지정합니다. +- 게시글에 추천·비추천 반응을 남길 수 있는 기능을 추가했습니다. 로그인한 회원은 게시글 상세에서 반응을 남기거나 다른 반응으로 바꾸거나 취소할 수 있고, 각 반응의 개수가 항상 함께 표시됩니다. 본인이 쓴 글에는 반응할 수 없으며, 열람 권한이 없어 본문을 볼 수 없는 비밀글에는 반응 버튼이 표시되지 않고 반응도 남길 수 없습니다. 게시판 관리 설정에서 반응 사용 여부와 사용할 반응 유형(추천·비추천)을 게시판별로 켜고 끌 수 있으며, 새 게시판을 만들 때 적용될 기본값도 환경설정에서 지정합니다. - 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. - 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다. - 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시). diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/ReactionNotAllowedException.php b/modules/_bundled/sirsoft-board/src/Exceptions/ReactionNotAllowedException.php index 243eda3b..00348264 100644 --- a/modules/_bundled/sirsoft-board/src/Exceptions/ReactionNotAllowedException.php +++ b/modules/_bundled/sirsoft-board/src/Exceptions/ReactionNotAllowedException.php @@ -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')); + } + /** * 거절 사유를 반환합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Services/ReactionService.php b/modules/_bundled/sirsoft-board/src/Services/ReactionService.php index b9bd9123..b9aaccbd 100644 --- a/modules/_bundled/sirsoft-board/src/Services/ReactionService.php +++ b/modules/_bundled/sirsoft-board/src/Services/ReactionService.php @@ -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); + } } diff --git a/modules/_bundled/sirsoft-board/src/lang/en/messages.php b/modules/_bundled/sirsoft-board/src/lang/en/messages.php index d2f7bb54..8c954633 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/messages.php @@ -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.', ], diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php index 99407a90..ecaa99e4 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php @@ -134,6 +134,7 @@ return [ 'disabled' => '이 게시판은 반응 기능이 비활성화되어 있습니다.', 'inactive_type' => '이 게시판에서 사용할 수 없는 반응 유형입니다.', 'self_post' => '본인 글에는 반응할 수 없습니다.', + 'secret_denied' => '열람 권한이 없는 비밀글에는 반응할 수 없습니다.', 'login_required' => '로그인이 필요한 기능입니다.', ], diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php index 06892019..4e25b425 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php @@ -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 diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-reaction-flow.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-reaction-flow.spec.ts index ed2316fb..a24dde8f 100644 --- a/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-reaction-flow.spec.ts +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-reaction-flow.spec.ts @@ -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 | 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 | 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 | null)?.reaction_type_id).toBe(DISLIKE_ID); }); // @scenario surface=detail_register_switch_remove diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-reaction.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-reaction.yaml index 65840b2e..3ad7d6b7 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-reaction.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-reaction.yaml @@ -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 diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx index 888cf26e..61a880ac 100644 --- a/templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx @@ -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; + 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'); + }); }); diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json b/templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json index a4431afc..0412b177 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json @@ -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": [ {