v7.0.0-beta.1 release

This commit is contained in:
HeuJung committed 2026-04-01 10:30:52 +09:00
commit 6595fd0eb5
3967 files changed
+1875383

No files matched your search

+508
View File
@@ -0,0 +1,508 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.1] - 2026-04-01
### Changed
- 오픈 베타 릴리즈
## [0.9.5] - 2026-03-31
### Added
- 이커머스 1:1 문의게시판 Extension Point에 `mode: "replace"` 적용 — 게시판 모듈 활성 시 default 폴백 메시지 교체
### Fixed
- 게시판 글 목록·신고 목록에서 다른 페이지로 이동 후 돌아올 때 이전 검색어가 Input에 유지되던 문제 수정 — `_global` 상태를 `_local`로 전환 (admin_board_posts_index.json, admin_board_reports_index.json)
## [0.9.4] - 2026-03-30
### Fixed
- 활동 로그 enum 미번역 수정 — Post, Comment, Board, Report 모델의 `$activityLogFields` status/설정 필드 `type: 'text'` → `type: 'enum'` 변경 + 6개 Enum 클래스에 `labelKey()` 메서드 추가
- 활동 로그 샘플 시더 enum 타입 동기화 — `ActivityLogSampleSeeder`의 status 관련 9건 `type: 'text'` → `type: 'enum'` 수정 및 `old_label_key`/`new_label_key` 추가
## [0.9.3] - 2026-03-30
### Fixed
- 관리자 다크모드 레이아웃 전수 수정 — 19개 파일에서 누락된 `dark:` variant 약 70건 추가 (text-gray, bg-white/gray, focus:ring, text-red/blue, border-gray 등)
## [0.9.2] - 2026-03-30
### Changed
- `window.G7Config` 설정 노출 최소화 — frontend_schema 4개 카테고리(basic_defaults, report_policy, spam_security, display) 전체 `expose: false` 처리 (프론트엔드 미참조)
## [0.9.1] - 2026-03-30
### Changed
- ReportRepository의 직접 `MATCH...AGAINST` 호출을 `DatabaseFulltextEngine::whereFulltext()` 정적 헬퍼로 전환 — 다중 DBMS 호환
- Post 모델에 `FulltextSearchable` 인터페이스 추가 — Scout 검색 파이프라인 지원
- `searchIndexShouldBeUpdated()` 훅 기반 전환 — 검색 플러그인이 Post 인덱스 업데이트 제어 가능
- 마이그레이션 FULLTEXT 인덱스 생성을 `DatabaseFulltextEngine::addFulltextIndex()` 정적 헬퍼로 전환
## [0.9.0] - 2026-03-30
### Added
- FULLTEXT 인덱스(ngram) 추가 — boards(name), boards_report_logs(snapshot) 총 2개
### Changed
- 신고 관리 검색 쿼리를 `LIKE '%keyword%'`에서 `MATCH...AGAINST IN BOOLEAN MODE`로 전환 — boards.name, boards_report_logs.snapshot 검색 성능 향상
## [0.8.1] - 2026-03-30
### Fixed
- 신규 게시판 생성 시 조회수 표시가 기본 OFF로 설정되던 문제 수정 — 기본값을 ON으로 변경 (#228)
- 신규 게시판 생성 시 최소 내용 길이 기본값이 10자로 설정되던 문제 수정 — 기본값을 2자로 완화 (#228)
- 파일 크기 설정 단위를 bytes에서 MB로 통일 — 관리자 설정 입력, DB 저장, 검증, 안내 문구 모두 MB 기준으로 변경 (#228)
- 게시글 삭제(소프트삭제) 시 댓글과 첨부파일이 함께 삭제되어 복원 시 미표시되던 문제 수정 — 게시글만 삭제하고 댓글/첨부파일은 유지 (#228)
## [0.8.0] - 2026-03-30
### Added
- 이커머스 1:1 문의 게시판 연동 지원
- 이커머스 환경설정 > 문의게시판 설정 화면에 게시판 선택 드롭다운, 게시판 수정 이동, 문의내역 관리, 관리자 메뉴 추가 버튼 동적 주입
- 문의/답변 생성·수정·삭제를 게시판 게시글로 처리하는 훅 리스너 추가
- `PostRepository`에 관계 포함 조회 메서드 3개 추가 (`findWithBoard`, `findByIdsWithRelations`, `findFirstReplyWithBoard`)
- 검색/필터/정렬 성능 향상을 위한 누락 인덱스 일괄 추가 — board_posts 2개([board_id+author_name] 비회원 작성자 검색, [board_id+status+created_at] 게시판별 상태+날짜 필터)
### Changed
- 문의 삭제/답변 삭제 시 게시판 알림이 발송되지 않도록 처리 방식 개선
- 외부 모듈 위임 방식에서 `skip_notification` 옵션 직접 체크 방식으로 변경
### Fixed
- 게시글 수정 후 `after_update` 훅이 트랜잭션 내부에서 실행되어 롤백 시 부작용이 발생할 수 있던 문제 수정
- `Board`, `Post` 모델 직접 조회를 Repository 경유로 교체
## [0.7.1] - 2026-03-27
### Fixed
- 게시판 관리 부모 메뉴 URL을 null로 변경 — 대응 라우트 없는 `/admin/boards` URL이 404 발생 (module.php)
## [0.7.0] - 2026-03-25
### Added
- BoardActivityLogListener: 게시판/게시판유형/게시물/댓글/첨부파일/신고 전체 로깅
- 활동 로그 다국어 키 33개 정의 (resources/lang/ko/activity_log.php, en/activity_log.php)
- $activityLogFields 메타데이터: Board, Post, Comment 모델
- ActivityLog 샘플 시더 (database/seeders/ActivityLogSampleSeeder.php)
- ActivityLogDescriptionResolver: 활동 로그 표시 시점에 게시판/게시물/댓글 엔티티명을 ID에서 해석하는 필터 훅 리스너
- BoardActivityLogListener: 신고 일괄 상태변경 before 스냅샷 + after ChangeDetector 추가 (1훅 추가, 총 32훅)
- BoardActivityLogListener: 신고 일괄 상태변경 per-Report 로깅 전환 (건별 loggable_id 기록)
### Fixed
- 활동 로그 description에 번역 키가 원문으로 노출되는 문제 수정 — src/lang 번역 파일 동기화, description_params 불일치 수정
- UpdateBoardTypeRequest: `name.ko` 로케일 하드코딩 메시지 제거 — LocaleRequiredTranslatable이 자체 메시지 반환
## [0.6.10] - 2026-03-26
### Added
- 사용자 게시판 목록/상세에서 manager 권한 사용자가 삭제된 게시글/댓글을 선택적으로 볼 수 있는 토글 기능 추가
### Fixed
- 게시판 권한 탭 manager/step TagInput UUID 불일치 수정 — options 및 change 핸들러 표현식에서 `u.id` → `u.uuid`로 수정 (4곳)
- `BoardController::getFormData()` 생성 모드에서 `board_manager_ids`에 uuid 반환하도록 수정
- `BoardSampleSeeder` — `pluck('id')` → `pluck('uuid')` 수정
- `StoreCommentRequest` — 서버 주입 필드(`user_id`, `ip_address`) 검증 규칙 제거 (규정 준수)
- `CommentController` — `prepareForValidation()`으로 처리된 `post_id` 중복 대입 제거
- 신고 시스템 API 응답 UUID 적용 — `ReportResource`, `ReportLogResource`, `ReportDetailResource`, `ReportService` `id` → `uuid` 전환
- 마이페이지 내 게시글 목록에서 비활성 게시판(`is_active=false`)의 게시글이 노출되던 문제 수정
- 공지글 쿼리에서 삭제 상태 공지 제외 누락 수정
- 삭제된 게시글 상세 접근 시 명시적 404 처리 추가
- 삭제된 게시글의 댓글을 권한 없는 사용자에게 반환하던 문제 수정
- 게시판 폼 탭 순서 통일 (기본정보 → 설정 → 권한)
- 대댓글 UI 들여쓰기 버그 수정
## [0.6.9] - 2026-03-24
### Added
- 게시판 설정 화면의 각 탭 이름을 한국어/영어로 표시할 수 있도록 다국어 키 추가
### Changed
- 게시판 설정 화면을 기능별 탭(기본, 권한, 목록, 게시글, 답변글, 댓글, 첨부파일, 알림, 일괄적용)으로 분리하여 레이아웃 파일 전면 재구성
- 변경된 탭 구조에 맞게 레이아웃 렌더링 테스트 업데이트
## [0.6.8] - 2026-03-23
### Changed
- API Resource: author.id → author.uuid 전환 (PostResource, CommentResource, BoardResource)
- PostResource: user_id FK → user.uuid 전환
- CommentResource: author.id → author.uuid 전환
- 관리자 레이아웃: author.id → author.uuid 참조 전환 (5개 파일)
- 유저 게시글 라우트: 정수 제약 제거 (Route Model Binding 호환)
- FormRequest: 정수 검증 → UUID 검증 전환 (StoreBoardRequest, StoreCommentRequest, UpdateBoardRequest)
- BoardService: UUID→정수 FK 변환 처리 추가
## [0.6.7] - 2026-03-21
### Fixed
- 공개 API 라우트 throttle을 60/분(댓글은 30/분)에서 600/분으로 일괄 상향 — 페이지 간 네비게이션 시 동일 카운터(sha1(userId)) 공유로 인한 429 Too Many Requests 방지 (게시글, 댓글, 첨부파일, 사용자 프로필, 마이페이지 활동 라우트 포함)
## [0.6.6] - 2026-03-21
### Added
- 게시판 날짜 표기 방식 개선 — `standard`(표준형) / `relative`(유동형) 전역 설정 추가
- 게시판 환경설정 `general` 탭 신규 추가 — 날짜 표시 방식 선택 UI
- 블라인드/삭제 게시글 댓글 작성 폼 숨김 및 안내 메시지 표시
### Changed
- `PostResource` `created_at` — 원본 datetime 반환으로 변경, 포맷된 날짜는 `created_at_formatted` 필드로 분리
- `SearchPostsListener` — 게시판별 N번 쿼리 → 단일 쿼리로 리팩토링
### Fixed
- 게시판 환경설정 권한 일괄적용 시 manager/step 역할이 초기화되던 문제 수정
- 게시판 권한 자동주입 정책 변경 — 게시판 생성/수정 시에만 적용, 일괄적용 시 제외
- 일괄적용 모달 열기 전 이전 오류 상태가 남아있던 문제 수정
- 이미지 미리보기(preview) API throttle을 게시판 API 그룹(60/분)에서 분리하여 전용 그룹(300/분)으로 변경 — 갤러리 게시판에서 다수 썸네일 로드 시 429 Too Many Requests 방지
## [0.6.5] - 2026-03-18
### Added
- `SeoBoardSettingsCacheListener` — 게시판 모듈 설정 변경 시 관련 SEO 캐시(board/index, board/show, board/boards, sitemap) 선별 무효화
### Changed
- `SeoBoardCacheListener` — 게시판별 독립 무효화로 전환 (`invalidateByLayout` → `invalidateByUrl` URL 기반), `search/index`·`home` 무효화 추가, 생성/수정 시 해당 게시물 상세 캐시 즉시 재생성(`SeoCacheRegenerator`)
## [0.6.4] - 2026-03-16
### Changed
- 마이그레이션 통합 — 증분 마이그레이션을 테이블당 1개 create 마이그레이션으로 정리
- 시더 디렉토리 분리 — 샘플 시더를 `Sample/` 하위로 이동
- 라이선스 프로그램 명칭 정비
- 게시판 환경설정 메일 템플릿 탭 레이아웃 개선
## [0.6.3] - 2026-03-13
### Added
- 신고 접수 시 관리자에게 알림 이메일 발송 기능 추가
- 신고 처리(블라인드/삭제/반려) 결과를 게시글 작성자에게 알림 이메일로 안내하는 기능 추가
- 관리자 알림 발송 범위 설정 추가 — 게시글당 최초 신고 시에만 발송하거나, 신고가 들어올 때마다 발송하는 옵션 선택 가능
- 관리자 역할별 신고 알림 수신 여부 설정 기능 추가
- 이미 신고한 게시글/댓글의 신고 버튼 비활성화 기능 추가
### Changed
- 신고 정책 설정 화면에서 알림 발송 범위를 더 직관적인 라디오 버튼 형태로 변경
- 신고 반려 처리 알림 문구 개선 — "반려(복원)"으로 명확하게 안내하도록 변경
- 블라인드 처리된 게시글 안내 메시지 통일
- 신고 알림 수신 관리자 역할 지정 방식 개선 — 중복 지정 없이 정확하게 반영되도록 수정
### Fixed
- 존재하지 않는 게시글 접근 시 404 페이지로 이동하지 않던 문제 수정
- 신고 일괄 처리 드롭다운이 정상 작동하지 않던 문제 수정
## [0.6.2] - 2026-03-12
### Added
- 신고 케이스 구조 전환 — 게시글/댓글당 케이스 1행 구조로 변경 (boards_reports + boards_report_logs 분리)
- 신고자 목록 페이지네이션 API 추가
- 신고 처리 이력 타임라인 표시
- ReportLogResource 추가
- manifest에 license 필드 및 LICENSE 파일 추가
### Changed
- 신고 목록 정렬 기준을 마지막 신고 일시 기준으로 변경
- 신고 일괄 처리 단순화 — 케이스 단위 직접 처리로 변경
- 게시판 샘플 시더 게시판 6개 → 8개로 확장
- 신고 샘플 시더 재신고자 없을 경우 방어 처리 추가
### Fixed
- 재활성 사이클 카운트 버그 수정 — 이전 사이클 신고 로그가 현재 사이클에 포함되던 문제
- 반려 시 복구 대상 케이스 상태 조건 불일치 수정
## [0.6.1] - 2026-03-11
### Changed
- `BoardResource` 응답의 `user_permissions` 키를 `user_abilities`로 변경 — `can_*` 형식 통일
- `getUserBoardPermissions()` 메서드의 반환 키를 `posts_write`, `manager` 등 underscore 형식에서 `can_write`, `can_manage` 등 `can_*` 형식으로 변경
- Admin/User PostController의 `include_user_permissions` 파라미터를 `include_user_abilities`로 변경
### Added
- `BoardUserAbilitiesTest` — BoardResource user_abilities 키 구조, PostResource/CommentResource abilities 키 검증 테스트 13개
## [0.6.0] - 2026-03-11
### Added
- 게시판별 동적 권한에 스코프(scope) 메타데이터 지원 추가 (`resource_route_key`, `owner_key`)
- `config/board.php` 권한 정의에 `scope` 키 추가 (12개 권한: admin/user 각 6개)
- `BoardPermissionService::ensureBoardPermissions()` — 권한 생성 시 scope 메타데이터 자동 설정
- `PostService` — 목록/상세 조회 시 `$context` 파라미터 기반 스코프 필터링 적용
- `CommentService::getCommentsByPostId()` — `$context` 파라미터 기반 스코프 필터링 적용
- `AttachmentService::download()` — `$context` 파라미터 기반 스코프 접근 제어 적용
- User 컨트롤러 전체에 `AccessDeniedHttpException` catch 및 403 응답 처리
- Admin PostController/AttachmentController에 `AccessDeniedHttpException` catch 추가
- `Upgrade_0_6_0.php` — 기존 게시판 동적 권한의 scope 메타데이터 백필 업그레이드 스크립트
- `BoardPermissionScopeServiceTest` — 서비스 레이어 스코프 필터링 테스트 10개
### Fixed
- User 컨트롤러에서 `getBoardBySlug()` 호출 시 `checkScope: false` 적용 — 매니저 역할 사용자 500 에러 수정
- `PostRepository::countNormalPosts()` — 누락된 스코프 필터링 추가
## [0.5.5] - 2026-03-11
### Fixed
- Board 모델 `$guarded`에서 `created_by`, `updated_by` 제거 후 `$fillable`로 이동 — 게시판 생성 시 생성자/수정자 ID가 DB에 저장되지 않던 문제 해결
- `BoardService::createBoard()`에 `Auth::id()` 기반 `created_by`/`updated_by` 설정 추가
- `BoardService::updateBoard()`에 `Auth::id()` 기반 `updated_by` 설정 추가
### Changed
- `ModuleTestCase`에 `$migrated` 플래그 추가 — 마이그레이션 체크를 프로세스당 1회로 최적화
## [0.5.4] - 2026-03-11
### Added
- `BoardService`, `ReportService`에 `PermissionHelper::applyPermissionScope()` / `checkScopeAccess()` 적용
- `BoardController`, `ReportController` 상세/수정/삭제 엔드포인트에 `AccessDeniedHttpException` 처리 추가
- 게시판 목록 조회 시 스코프 필터링 적용 (본인만/역할/전체)
- 게시판 상세/수정/삭제/복사 시 스코프 접근 제어 적용
- 신고 상세/상태변경/삭제 시 스코프 접근 제어 적용
### Fixed
- `PostCollection::toArray()` 클로저에 `$request` 변수 미전달 버그 수정 (Undefined variable $request)
- `BoardPermissionTest` 테스트 assertions를 `permissions` → `abilities` 키로 수정
- `PostResourcePermissionTest` 테스트 assertions를 `permissions` → `abilities` 키로 수정
## [0.5.3] - 2026-03-11
### Added
- 게시판 설정 화면에 `isReadOnly` 패턴 적용 (읽기 전용 배너, 모든 폼 필드 비활성화, 저장 버튼 비활성화)
- 게시판 폼 화면에 `isReadOnly` 패턴 적용 (기본정보/권한설정/목록설정/게시글설정/알림설정 탭 전체)
- 게시글 폼 화면에 `isReadOnly` 패턴 적용 (제목/본문/카테고리/옵션/첨부파일 비활성화)
- `BoardSettingsController`에 abilities 응답 추가 (`can_update`)
- 게시판 목록 DS에 403 errorHandling 추가
- 게시판 폼 DS에 403 errorHandling 및 roles DS fallback 추가
- 게시판 설정 DS에 403 errorHandling 및 보조 DS fallback 추가
### Changed
- 게시판 목록 "게시판 추가" 버튼: `if`(숨김) → `disabled`(비활성화)로 변경
- 게시글 목록 "게시글 작성" 버튼: `if`(숨김) → `disabled`(비활성화)로 변경
- 게시글 상세 답글 버튼: `if`(숨김) → `disabled`(비활성화)로 변경
- 게시글 상세 ActionMenu: 권한 없는 항목을 숨김 대신 `disabled` 처리
- 댓글 답글 버튼 및 ActionMenu: 권한 없는 항목을 숨김 대신 `disabled` 처리
- 댓글 입력 영역: `if`에서 ability 조건 분리 → 내부 Textarea/Button `disabled` 처리
- 신고 목록 일괄 작업 버튼: `if`(숨김) → `disabled`(비활성화)로 변경
- 신고 상세 상태 변경 버튼: `if`에서 ability 조건 분리 → `disabled` 처리
- 블라인드/삭제 댓글 원본 보기 버튼: `if`(숨김) → `disabled`(비활성화)로 변경
- 게시판 유형 관리 모달에 `isReadOnly` 기반 disabled 추가
### Fixed
- `ReportCollection`의 `permissions` → `abilities` 키 불일치 수정 (신고 목록 일괄 작업 미표시 버그)
- `ReportDetailResource`의 `permissions` → `abilities` 키 불일치 수정 (신고 상세 상태 변경 버튼 미표시 버그)
## [0.5.2] - 2026-03-10
### Changed
- 권한 카테고리에 `resource_route_key`, `owner_key` 스코프 메타데이터 추가 (boards, reports)
- 라우트 미들웨어 `except:owner:*` 옵션 제거 (scope_type 데이터 기반 시스템으로 전환)
- Repository 목록 조회에 `PermissionHelper::applyPermissionScope()` 적용 (Board, Post, Comment, Report)
- `PermissionBypassable` 인터페이스 및 `getBypassUserId()` 제거 (Board, Post, Comment, Report, Attachment 모델)
## [0.5.1] - 2026-03-10
### Changed
- API 리소스 권한 플래그 키 `permissions` → `abilities`로 변경 (코어 표준화)
- `permissionMap()` → `abilityMap()`, `resolvePermissions()` → `resolveAbilities()` 메서드명 변경
- `BoardCollection.withPermissions()` 응답 키 `permissions` → `abilities`
- `PostCollection` 응답 키 `permissions` → `abilities`
- 기존 `board_permissions` 키를 원래 이름 `permissions`로 원복 (충돌 해소)
- 관리자 레이아웃 JSON의 `permissions.can_*` / `permissions?.can_*` 바인딩을 `abilities.can_*` / `abilities?.can_*`로 변경
- 사용자 레이아웃 JSON의 `permissions?.can_view` 바인딩을 `abilities?.can_view`로 변경
### Fixed
- 알림 채널(이메일/사이트 알림) 일괄 적용 시 채널 설정이 저장되지 않는 버그 수정
- 일괄 적용 모달에서 알림 채널 항목 선택 시 체크박스가 정상 동작하지 않는 버그 수정
- 일괄 적용 모달에서 알림 채널 항목명이 키 코드로 표시되는 다국어 누락 수정
## [0.5.0] - 2026-03-06
### Changed
- 메일 템플릿 목록 API에 검색/필터/페이지네이션 지원 추가
- 메일 템플릿 편집 모달 UX 개선 (blur_until_loaded, sticky footer, 함수형 body)
- `getDefaultTemplateData()`를 Controller에서 Service로 이동 (Service-Repository 패턴 준수)
- 메일 템플릿 탭 UI를 코어 환경설정과 동일한 구조로 변경
### Added
- 메일 템플릿 미리보기(preview) API 엔드포인트 추가
- 메일 템플릿 검색 기능 (제목/본문/전체)
- 페이지당 항목 수 선택 및 페이지네이션
- 메일 템플릿 관련 다국어 키 추가 (검색/필터/빈 상태/편집 모달)
## [0.4.0] - 2026-03-05
### Added
- DB 기반 메일 템플릿 시스템 (5종: 새 댓글, 대댓글, 답변글, 관리자 처리, 관리자 새 게시글)
- `board_mail_templates` 테이블, BoardMailTemplate 모델/서비스/리포지토리/컨트롤러
- 메일 템플릿 관리 API 및 환경설정 UI
- 업그레이드 스텝 (`Upgrade_0_4_0`) — 기존 설치에 메일 템플릿 초기 시딩
### Changed
- 5개 알림 클래스 DbTemplateMail 기반으로 전환 (Blade → DB 기반)
### Removed
- `BoardNotificationMail`, `board-notification.blade.php` 삭제
## [0.3.2] - 2026-03-06
### Added
- 게시판 유형(BoardType)을 독립 엔티티로 분리 — `board_types` 테이블, `BoardType` 모델
- 게시판 유형 API — `BoardTypeController`, `BoardTypeService`, `BoardTypeRepository` 추가
- 게시판 유형 요청/응답 클래스 — `StoreBoardTypeRequest`, `UpdateBoardTypeRequest`, `BoardTypeResource` 추가
- 환경설정 > 기본설정 탭에 게시판 유형 관리 모달 추가 (생성 / 수정 / 삭제)
- 게시판 유형 삭제 시 사용 중인 게시판 수 검증 (사용 중이면 삭제 불가)
- 기본값으로 설정된 게시판 유형 삭제 방지 로직 추가
- 미지원 게시판 유형에 대한 fallback 블록 추가 (basic 레이아웃으로 표시)
### Changed
- 게시판 생성/수정 폼에서 유형 관리 버튼 제거 — 환경설정 > 기본설정에서 일원 관리하도록 UX 변경
- 게시판 폼 유형 Select 하단에 관리 안내 힌트 텍스트 추가
- 게시판 폼 및 환경설정 기본 게시판 유형 Select에 slug 함께 표시 + 긴 목록 스크롤 처리
- 게시판 유형 slug 허용 문자 규칙을 하이픈(-) 통일 (기존 언더스코어(_) 혼용에서 변경)
### Fixed
- 게시판 유형 모달에서 저장 실패 시 필드별 에러 메시지 표시 및 빨간 테두리 강조
- 테스트 환경에서 `Accept-Language` 헤더 미설정 시 `name.ko` 필수 검증이 통과되던 문제 수정
## [0.3.1] - 2026-03-05
### Added
- 신고 남발 방지 기능 — 일일 신고 횟수 제한, 반려 누적 시 신고 차단, 연속 신고 쿨타임
- 관리자 환경설정 신고 정책 탭을 "자동 숨김 설정" / "신고 제한 설정" 2개 카드로 분리
- 연속 신고 쿨타임 설정 항목 추가 (기본 60초, 0이면 비활성화)
- 각 설정 항목에 동작 설명 안내 텍스트 보강
- 관리자 신고 목록에 마지막 신고일시 표시 (2건 이상 신고 시)
- 관리자 신고 목록/상세에 비회원 신고자 배지 표시
- 비회원 신고 샘플 시더 데이터 추가
### Changed
- 쿨타임 설정 변경 시 즉시 반영되도록 설정 조회 방식 변경
- 쿨타임 메시지를 신고 전용으로 분리
### Fixed
- 신고 모달에서 사유 미선택 시 콘솔 오류가 발생하던 문제 수정
- 댓글 신고 시 관리자 목록에서 게시글 제목이 표시되지 않던 문제 수정
- 관리자 신고 목록에서 페이지/정렬 변경 시 체크박스 선택이 유지되던 문제 수정
- 연속 신고 쿨타임 설정값이 저장되지 않던 문제 수정
### Removed
- "신고 차단 기간" 설정 항목 제거 — 실제 동작과 불일치하여 혼란 유발
## [0.3.0] - 2026-03-03
### Added
- 마이페이지 "내가 쓴 댓글" API (`GET /me/my-comments`) — Comment 기반 페이지네이션
- `CommentRepository::getUserComments()` — 삭제 게시글·비활성 게시판 댓글 자동 제외
- `PostRepository::findByBoardId()`, `CommentRepository::findByBoardId()` — 신고 처리용 Repository 메서드 추가
### Changed
- `board_posts`, `board_comments`, `board_attachments` 단일 테이블 전환 (기존 슬러그별 동적 테이블 폐지)
- `board_id` 파티션(`PARTITION BY LIST`)으로 게시판 분리 — 게시판 추가 시 파티션 동적 생성
- `DynamicPost/Comment/Attachment` 모델 → `Post/Comment/Attachment` 단일 모델로 통합
- Repository 전체 `setTableBySlug()` 패턴 → `where('board_id', ...)` 단일 쿼리 패턴으로 전환
- 마이페이지 게시판 활동 조회 PHP `forPage()` → DB `paginate()` 전환 (메모리 부족·타임아웃 해결)
- 통계 `total_comments` 집계 조건을 목록 API와 동일하게 INNER JOIN 적용 (삭제 게시글 댓글 제외)
- Admin 라우트에 `admin` 미들웨어 추가 (5개 그룹)
- `ReportService` — `logger()` 헬퍼 → `Log::` 파사드 교체
- `BoardService::clearBoardCaches()` — `Cache::forget()` → `CacheService::forget()` 통일
- `PostService`, `CommentService` — actionLog 배열 생성 중복 → `buildActionLog()` 메서드 공통화
### Fixed
- raw SQL 내 테이블명에 DB prefix 미적용 문제 수정
- `DB::table('x as y')` alias에 prefix 이중 적용되는 문제 수정
- 답글 작성 시 삭제된 게시글 체크 후 조기 종료 누락 버그 수정, 공지 답글 금지 및 깊이 제한 추가
- 댓글 작성 시 삭제된 댓글 체크 후 조기 종료 누락 버그 수정, 깊이 제한 추가
- 게시글 목록에서 depth 2 이상 답글이 표시되지 않는 버그 수정
- 관리자 게시글 목록 답글 depth 시각화 — depth에 비례한 들여쓰기 적용
- 관리자 답글 버튼 — 공지글이 아니고 최대 답글 깊이 미만인 게시글에만 표시되도록 조건 수정
- 인기글 목록이 HTTP 200이지만 빈 배열로 반환되는 버그 수정 (raw SQL DB prefix 누락)
- 게시글·댓글 API 응답에서 비밀번호 필드 노출 방지
- 게시글·댓글 트리거 타입을 Enum으로 올바르게 처리
- 댓글 단건 조회 시 소프트 삭제된 댓글이 조회되지 않는 문제 수정
- 신고 처리 시 DB 직접 접근 → Repository 패턴으로 교체
- 게시글 상세 조회 비즈니스 로직을 Controller에서 Service로 이동
- 신고 상태별 집계 로직을 Controller에서 Service로 이동
- 신고 가능 여부·본인 콘텐츠 검증 로직을 Controller에서 Service로 이동
### Removed
- `BoardTableService` — 동적 테이블 생성 서비스 삭제
- `TableCreationException` 삭제
- `DynamicPost`, `DynamicComment`, `DynamicAttachment` 모델 삭제
- `getUserCommentedActivities()` (PostRepository) — 미사용 메서드 (마이댓글 API 전환으로 대체)
## [0.2.0] - 2026-02-26
### Added
- 환경설정 페이지 (3탭: 기본 설정 / 신고 정책 / 스팸·보안)
- 환경설정 CRUD API (`GET/PUT /admin/settings`, `GET /admin/settings/{category}`)
- 일괄 적용 API (`POST /admin/settings/bulk-apply`) — 체크된 항목을 선택 게시판에 일괄 적용
- 캐시 초기화 API (`POST /admin/settings/clear-cache`)
- `BoardSettingsService` (ModuleSettingsInterface 구현)
- `boards` 테이블에 `max_reply_depth`(기본 5), `max_comment_depth`(기본 10) 컬럼 추가
- 게시판 폼에 답변글 최대 depth(1~5), 대댓글 최대 depth(1~10) 필드 추가
- 게시판 폼 알림 탭에 알림별 채널 선택 UI 추가 (필터 훅 `sirsoft-board.notification.channels`로 확장 가능)
### Changed
- `config/board.php`의 기본값(`defaults`, `default_board_permissions`, `blocked_keywords`, `view_count_*`)을 ModuleSettings 시스템으로 이관
- `config/board.php`에서 `report_types` 제거 (dead code, `ReportReasonType` Enum이 원본)
- 기존 코드의 `config()` 참조를 `g7_module_settings()` 헬퍼로 전환 (9개 파일)
- `boards` 테이블 기본값 통일: `show_view_count=false`, `notify_admin_on_post=true`, `notify_author=true`
- 조회수 중복 방지 방식을 단일 캐시 TTL 방식으로 통합 (세션 방식 제거, API 미들웨어에 `StartSession` 미적용)
### Fixed
- 권한 TagInput 수정 시 저장 버튼이 표시되지 않는 문제 수정
- 일괄 적용 모달에서 `fields` 값이 전달되지 않는 문제 수정
- `.` 포함 권한 키(예: `sirsoft-board.posts.read`)가 `BulkApplySettingsRequest` 검증에서 422 오류 발생하는 문제 수정
## [0.1.2] - 2026-02-25
### Changed
- 모듈 라우트 admin/user 분기 서빙 적용 (routes.json → routes/admin.json 이동)
## [0.1.1] - 2026-02-24
### Changed
- 버전 체계 조정 (정식 출시 전 0.x 체계로 변경)
## [0.1.0] - 2026-02-23
### Added
- 게시판 모듈 초기 구현
- 게시판 CRUD (생성, 읽기, 수정, 삭제) 기능
- 게시판별 스킨 설정, 카테고리 관리
- 게시물 CRUD 기능 (작성, 조회, 수정, 삭제)
- 비밀글 기능: 작성자/관리자만 열람 가능
- 댓글 시스템: CRUD, 대댓글 (계층형 댓글)
- 게시물 검색 기능 (제목, 내용, 작성자 검색)
- 첨부파일: 이미지/파일 업로드, 다운로드, 인라인 표시
- 카드/갤러리 레이아웃: 다양한 게시물 목록 표시 형태 지원
- 인기글/최신글 위젯
- SEO 메타 태그 자동 생성
- 관리자 레이아웃 (게시판 관리, 게시물 관리, 댓글 관리)
- 사용자 레이아웃 (게시판 목록, 상세, 작성/수정)
- 권한 시스템 연동 (게시판별 읽기/쓰기/댓글/관리 권한)
- 다국어 지원 (ko, en)
+47
View File
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 게시판 모듈 (sirsoft-board)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,18 @@
{
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
"version": "1.0.0-beta.1",
"license": "MIT",
"autoload": {
"psr-4": {
"Modules\\Sirsoft\\Board\\": "src/",
"Modules\\Sirsoft\\Board\\Database\\Seeders\\": "database/seeders/",
"Modules\\Sirsoft\\Board\\Database\\Factories\\": "database/factories/",
"Modules\\Sirsoft\\Board\\Upgrades\\": "upgrades/"
}
},
"require": {
"php": "^8.2"
}
}
@@ -0,0 +1,175 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| 첨부파일 저장 설정
|--------------------------------------------------------------------------
*/
'attachment' => [
'disk' => env('SIRSOFT_BOARD_ATTACHMENT_DISK', 'modules'),
],
/*
|--------------------------------------------------------------------------
| 게시판별 권한 정의 (name, description)
|--------------------------------------------------------------------------
| 게시판별 동적 권한 생성 시 사용되는 권한 메타데이터입니다.
| BoardPermissionService에서 permissions 테이블에 레코드 생성 시 사용됩니다.
|
| 권한 구조:
| - admin.* : 관리자 권한 (type: admin, identifier: sirsoft-board.{slug}.admin.*)
| - 일반 키 : 사용자 권한 (type: user, identifier: sirsoft-board.{slug}.*)
|--------------------------------------------------------------------------
*/
'board_permission_definitions' => [
// ========================================
// 관리자 권한 (type: admin)
// ========================================
'admin.posts.read' => [
'name' => ['ko' => '게시글 조회 (관리자)', 'en' => 'View Posts (Admin)'],
'description' => ['ko' => '관리자 페이지에서 게시글 조회', 'en' => 'View posts in admin page'],
'scope' => ['resource_route_key' => 'post', 'owner_key' => 'user_id'],
],
'admin.posts.write' => [
'name' => ['ko' => '게시글 작성/수정/삭제 (관리자)', 'en' => 'Create/Edit/Delete Posts (Admin)'],
'description' => ['ko' => '관리자 페이지에서 게시글 작성 및 수정/삭제', 'en' => 'Create and edit/delete posts in admin page'],
],
'admin.posts.read-secret' => [
'name' => ['ko' => '비밀글 조회 (관리자)', 'en' => 'View Secret Posts (Admin)'],
'description' => ['ko' => '관리자 페이지에서 비밀글 조회', 'en' => 'View secret posts in admin page'],
'scope' => ['resource_route_key' => 'post', 'owner_key' => 'user_id'],
],
'admin.comments.read' => [
'name' => ['ko' => '댓글 조회 (관리자)', 'en' => 'View Comments (Admin)'],
'description' => ['ko' => '관리자 페이지에서 댓글 조회', 'en' => 'View comments in admin page'],
'scope' => ['resource_route_key' => 'comment', 'owner_key' => 'user_id'],
],
'admin.comments.write' => [
'name' => ['ko' => '댓글 작성/수정/삭제 (관리자)', 'en' => 'Create/Edit/Delete Comments (Admin)'],
'description' => ['ko' => '관리자 페이지에서 댓글 작성 및 수정/삭제', 'en' => 'Create and edit/delete comments in admin page'],
],
'admin.attachments.upload' => [
'name' => ['ko' => '파일 업로드/삭제 (관리자)', 'en' => 'Upload/Delete Files (Admin)'],
'description' => ['ko' => '관리자 페이지에서 파일 업로드 및 삭제', 'en' => 'Upload and delete files in admin page'],
'scope' => ['resource_route_key' => 'attachment', 'owner_key' => 'created_by'],
],
'admin.attachments.download' => [
'name' => ['ko' => '파일 다운로드 (관리자)', 'en' => 'Download Files (Admin)'],
'description' => ['ko' => '관리자 페이지에서 파일 다운로드', 'en' => 'Download files in admin page'],
'scope' => ['resource_route_key' => 'attachment', 'owner_key' => 'created_by'],
],
'admin.manage' => [
'name' => ['ko' => '게시판 관리 (관리자)', 'en' => 'Manage Board (Admin)'],
'description' => ['ko' => '타인 게시글/댓글 관리, 블라인드, 복원, 공지 설정', 'en' => 'Manage others posts/comments, blind, restore, set notice'],
'scope' => ['resource_route_key' => 'board', 'owner_key' => 'created_by'],
],
// ========================================
// 사용자 권한 (type: user)
// ========================================
'posts.read' => [
'name' => ['ko' => '게시글 조회', 'en' => 'View Posts'],
'description' => ['ko' => '게시글 목록 및 상세 조회', 'en' => 'View post list and details'],
'scope' => ['resource_route_key' => 'post', 'owner_key' => 'user_id'],
],
'posts.write' => [
'name' => ['ko' => '게시글 작성/수정/삭제', 'en' => 'Create/Edit/Delete Posts'],
'description' => ['ko' => '게시글 작성 및 본인 글 수정/삭제', 'en' => 'Create posts and edit/delete own posts'],
],
'posts.read-secret' => [
'name' => ['ko' => '비밀글 조회', 'en' => 'View Secret Posts'],
'description' => ['ko' => '비밀글 조회', 'en' => 'View secret posts'],
'scope' => ['resource_route_key' => 'post', 'owner_key' => 'user_id'],
],
'comments.read' => [
'name' => ['ko' => '댓글 조회', 'en' => 'View Comments'],
'description' => ['ko' => '댓글 목록 조회', 'en' => 'View comment list'],
'scope' => ['resource_route_key' => 'comment', 'owner_key' => 'user_id'],
],
'comments.write' => [
'name' => ['ko' => '댓글 작성/수정/삭제', 'en' => 'Create/Edit/Delete Comments'],
'description' => ['ko' => '댓글 작성 및 본인 댓글 수정/삭제', 'en' => 'Create comments and edit/delete own comments'],
],
'attachments.upload' => [
'name' => ['ko' => '파일 업로드/삭제', 'en' => 'Upload/Delete Files'],
'description' => ['ko' => '게시글에 파일 첨부 및 삭제', 'en' => 'Attach and delete files to posts'],
'scope' => ['resource_route_key' => 'attachment', 'owner_key' => 'created_by'],
],
'attachments.download' => [
'name' => ['ko' => '파일 다운로드', 'en' => 'Download Files'],
'description' => ['ko' => '첨부파일 다운로드', 'en' => 'Download attached files'],
'scope' => ['resource_route_key' => 'attachment', 'owner_key' => 'created_by'],
],
'manager' => [
'name' => ['ko' => '게시판 관리 (사용자)', 'en' => 'Manage Board (User)'],
'description' => ['ko' => '사용자 페이지에서 게시판 관리', 'en' => 'Manage board in user page'],
'scope' => ['resource_route_key' => 'board', 'owner_key' => 'created_by'],
],
],
/*
|--------------------------------------------------------------------------
| 제한값
|--------------------------------------------------------------------------
*/
'limits' => [
'per_page_min' => 5,
'per_page_max' => 100,
// 제목 길이 제한 (VARCHAR(200))
'min_title_length_min' => 0,
'min_title_length_max' => 200,
'max_title_length_min' => 1,
'max_title_length_max' => 200,
// 내용 길이 제한 (LONGTEXT)
'min_content_length_min' => 0,
'min_content_length_max' => 10000,
'max_content_length_min' => 1,
'max_content_length_max' => 50000,
// 댓글 길이 제한 (TEXT)
'min_comment_length_min' => 0,
'min_comment_length_max' => 1000,
'max_comment_length_min' => 1,
'max_comment_length_max' => 1000,
// 파일 업로드 제한 (MB 단위)
'max_file_size_min' => 1,
'max_file_size_max' => 200,
'max_file_count_min' => 1,
'max_file_count_max' => 20,
'category_max' => 50,
// 답글 깊이 제한
'max_reply_depth_min' => 1,
'max_reply_depth_max' => 10,
// 대댓글 깊이 제한
'max_comment_depth_min' => 0,
'max_comment_depth_max' => 10,
],
/*
|--------------------------------------------------------------------------
| 캐시 설정
|--------------------------------------------------------------------------
| 게시판 통계, 최근 게시글, 인기 게시판 등에 적용되는 캐시 설정입니다.
*/
'cache' => [
'enabled' => env('BOARD_CACHE_ENABLED', true),
'ttl' => env('BOARD_CACHE_TTL', 60), // 통계/최근글/인기게시판 모두 적용 (초 단위)
],
/*
|--------------------------------------------------------------------------
| 성능 제한
|--------------------------------------------------------------------------
| 대용량 게시판 환경에서의 성능 최적화를 위한 설정입니다.
*/
'performance' => [
'max_union_boards' => 50, // UNION 쿼리 최대 게시판 수
],
];
@@ -0,0 +1,105 @@
{
"_meta": {
"version": "1.0.0",
"description": "게시판 모듈 환경설정",
"categories": ["basic_defaults", "report_policy", "spam_security", "display"]
},
"defaults": {
"basic_defaults": {
"type": "basic",
"per_page": 20,
"per_page_mobile": 15,
"order_by": "created_at",
"order_direction": "DESC",
"secret_mode": "disabled",
"use_comment": true,
"use_reply": true,
"max_reply_depth": 5,
"max_comment_depth": 10,
"comment_order": "ASC",
"show_view_count": true,
"use_report": false,
"min_title_length": 2,
"max_title_length": 200,
"min_content_length": 2,
"max_content_length": 10000,
"min_comment_length": 2,
"max_comment_length": 1000,
"use_file_upload": false,
"max_file_size": 10,
"max_file_count": 5,
"allowed_extensions": ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "hwp", "txt", "zip"],
"notify_admin_on_post": true,
"notify_author": true,
"new_display_hours": 24,
"blocked_keywords": [
"시발", "씨발", "ㅅㅂ", "ㅆㅂ", "좆", "ㅈㄴ", "존나", "씹",
"지랄", "ㅈㄹ", "개새끼", "새끼", "ㅅㄲ", "개자식",
"병신", "ㅂㅅ", "미친놈", "미친년", "느금마", "니애미", "느금",
"엠창", "엠뒤", "빡대가리", "개같은", "개년", "개놈", "개돼지", "개씹", "개쓰레기",
"닥쳐", "꺼져", "죽어", "자살해", "뒤져", "저능아", "정신병자",
"썅", "쌍놈", "쌍년", "쉬발", "쒸발", "시팔", "씨팔",
"한남", "한녀", "김치녀", "된장녀", "맘충", "틀딱", "느개비",
"장애인놈", "병신새끼", "흑형", "짱깨", "쪽바리", "왜놈", "튀기", "똥남아",
"야동", "포르노", "섹스", "야사", "음란", "성인사이트", "조건만남",
"원나잇", "섹파", "오피", "키스방", "매춘", "성매매", "몸파는"
],
"notify_admin_on_post_channels": ["mail"],
"notify_author_channels": ["mail"],
"default_board_permissions": {
"admin.posts.read": ["admin"],
"admin.posts.write": ["admin"],
"admin.posts.read-secret": ["admin"],
"admin.comments.read": ["admin"],
"admin.comments.write": ["admin"],
"admin.attachments.upload": ["admin"],
"admin.attachments.download": ["admin"],
"admin.manage": ["admin"],
"posts.read": ["admin", "user", "guest"],
"posts.write": ["admin", "user"],
"posts.read-secret": ["admin"],
"comments.read": ["admin", "user", "guest"],
"comments.write": ["admin", "user"],
"attachments.upload": ["admin", "user"],
"attachments.download": ["admin", "user", "guest"],
"manager": ["admin"]
}
},
"report_policy": {
"auto_hide_threshold": 5,
"auto_hide_target": "both",
"daily_report_limit": 10,
"rejection_limit_count": 5,
"rejection_limit_days": 30,
"notify_admin_on_report": true,
"notify_admin_on_report_scope": "per_case",
"notify_admin_on_report_channels": ["mail"],
"notify_author_on_report_action": true,
"notify_author_on_report_action_channels": ["mail"]
},
"spam_security": {
"post_cooldown_seconds": 0,
"comment_cooldown_seconds": 0,
"report_cooldown_seconds": 60,
"view_count_cache_ttl": 86400
},
"display": {
"date_display_format": "standard"
}
},
"frontend_schema": {
"_comment": "프론트엔드에서 미참조 — 전체 카테고리 노출 제외",
"basic_defaults": {
"expose": false
},
"report_policy": {
"expose": false
},
"spam_security": {
"expose": false
},
"display": {
"expose": false
}
}
}
@@ -0,0 +1,59 @@
<?php
namespace Modules\Sirsoft\Board\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Modules\Sirsoft\Board\Models\Board;
/**
* 게시판 팩토리
*
* 테스트용 게시판 데이터 생성
*/
class BoardFactory extends Factory
{
/**
* 모델 클래스
*/
protected $model = Board::class;
/**
* 모델 기본 상태 정의
*/
public function definition(): array
{
return [
'name' => [
'ko' => $this->faker->words(3, true),
'en' => $this->faker->words(3, true),
],
'slug' => $this->faker->unique()->lexify('test-board-??????'),
'type' => 'basic',
'is_active' => true,
'per_page' => 20,
'per_page_mobile' => 15,
'order_by' => 'created_at',
'order_direction' => 'DESC',
'secret_mode' => 'disabled',
'use_comment' => true,
'use_reply' => true,
'comment_order' => 'ASC',
'show_view_count' => false,
'use_report' => false,
'use_file_upload' => false,
'max_file_size' => 10485760, // 10MB
'max_file_count' => 5,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'],
'min_title_length' => 2,
'max_title_length' => 200,
'min_content_length' => 10,
'max_content_length' => 10000,
'min_comment_length' => 2,
'max_comment_length' => 1000,
'notify_admin_on_post' => true,
'notify_author' => true,
'blocked_keywords' => [],
'categories' => [],
];
}
}
@@ -0,0 +1,114 @@
<?php
namespace Modules\Sirsoft\Board\Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Models\Report;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\Modules\Sirsoft\Board\Models\Report>
*/
class ReportFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var class-string<\Illuminate\Database\Eloquent\Model>
*/
protected $model = Report::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
static $targetIdCounter = 0;
return [
'board_id' => 1, // 테스트에서 오버라이드 필수
'target_type' => fake()->randomElement(['post', 'comment']),
'target_id' => ++$targetIdCounter,
'author_id' => User::factory(),
'status' => ReportStatus::Pending,
'last_reported_at' => now(),
'last_activated_at' => null,
'processed_by' => null,
'processed_at' => null,
'process_histories' => null,
'metadata' => [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
],
];
}
/**
* pending 상태로 설정
*/
public function pending(): static
{
return $this->state(fn (array $attributes) => [
'status' => ReportStatus::Pending,
'processed_by' => null,
'processed_at' => null,
]);
}
/**
* review 상태로 설정
*/
public function review(): static
{
return $this->state(fn (array $attributes) => [
'status' => ReportStatus::Review,
]);
}
/**
* rejected 상태로 설정
*/
public function rejected(): static
{
return $this->state(fn (array $attributes) => [
'status' => ReportStatus::Rejected,
'processed_by' => 1,
'processed_at' => now(),
]);
}
/**
* suspended 상태로 설정
*/
public function suspended(): static
{
return $this->state(fn (array $attributes) => [
'status' => ReportStatus::Suspended,
'processed_by' => 1,
'processed_at' => now(),
]);
}
/**
* post 타입으로 설정
*/
public function post(): static
{
return $this->state(fn (array $attributes) => [
'target_type' => 'post',
]);
}
/**
* comment 타입으로 설정
*/
public function comment(): static
{
return $this->state(fn (array $attributes) => [
'target_type' => 'comment',
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Sirsoft\Board\Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Modules\Sirsoft\Board\Enums\ReportReasonType;
use Modules\Sirsoft\Board\Models\ReportLog;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\Modules\Sirsoft\Board\Models\ReportLog>
*/
class ReportLogFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var class-string<\Illuminate\Database\Eloquent\Model>
*/
protected $model = ReportLog::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'report_id' => null, // 테스트에서 오버라이드 필수
'reporter_id' => User::factory(),
'snapshot' => [
'board_name' => fake()->word(),
'title' => fake()->sentence(),
'content' => fake()->paragraph(),
'content_mode' => 'text',
'author_name' => fake()->name(),
],
'reason_type' => fake()->randomElement(ReportReasonType::cases())->value,
'reason_detail' => fake()->sentence(),
'metadata' => [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
],
];
}
}
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 게시판 유형
Schema::create('board_types', function (Blueprint $table) {
$table->id()->comment('게시판 유형 ID');
$table->string('slug', 50)->unique()->comment('유형 식별자 (basic, card, gallery 등)');
$table->text('name')->comment('유형명 (다국어: {"ko": "기본형", "en": "Basic List"})');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('board_types')) {
Schema::dropIfExists('board_types');
}
}
};
@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 게시판 설정
Schema::create('boards', function (Blueprint $table) {
$table->id()->comment('게시판 ID');
$table->text('name')->comment('게시판명 (다국어 JSON)');
$table->string('slug', 50)->unique()->comment('게시판 슬러그 (URL/테이블명)');
$table->mediumText('description')->nullable()->comment('게시판 설명 (다국어 JSON)');
$table->boolean('is_active')->default(true)->comment('게시판 활성화 여부 (1: 활성화, 0: 비활성화)');
$table->unsignedInteger('per_page')->default(20)->comment('페이지당 게시글 수 (PC)');
$table->unsignedInteger('per_page_mobile')->default(15)->comment('페이지당 게시글 수 (Mobile)');
$table->string('order_by', 20)->default('created_at')->comment('정렬 기준 (created_at, view_count, title, author)');
$table->string('order_direction', 4)->default('DESC')->comment('정렬 방향 (ASC, DESC)');
$table->string('type', 50)->default('basic')->index()->comment('게시판 타입 (basic, gallery, card 등)');
$table->text('categories')->nullable()->comment('분류 목록 (배열)');
$table->boolean('show_view_count')->default(false)->comment('조회수 노출');
$table->enum('secret_mode', ['disabled', 'enabled', 'always'])->default('disabled')->comment('비밀글 설정 (disabled: 사용안함, enabled: 사용함, always: 고정)');
$table->boolean('use_comment')->default(true)->comment('댓글 기능 사용');
$table->boolean('use_reply')->default(true)->comment('게시글 답변 기능 사용 (댓글에 대한 답글 아님)');
$table->unsignedSmallInteger('max_reply_depth')->default(5)->comment('답변글 최대 깊이 (1~5)');
$table->boolean('use_report')->default(false)->comment('게시글/댓글 신고 기능 사용');
$table->unsignedInteger('new_display_hours')->default(24)->comment('신규 게시글 표시 기간 (시간 단위)');
$table->unsignedInteger('min_title_length')->default(2)->comment('최소 제목 글자 수');
$table->unsignedInteger('max_title_length')->default(200)->comment('최대 제목 글자 수');
$table->unsignedInteger('min_content_length')->default(10)->comment('최소 게시글 글자 수');
$table->unsignedInteger('max_content_length')->default(10000)->comment('최대 게시글 글자 수');
$table->unsignedInteger('min_comment_length')->default(2)->comment('최소 댓글 글자 수');
$table->unsignedInteger('max_comment_length')->default(1000)->comment('최대 댓글 글자 수');
$table->boolean('use_file_upload')->default(false)->comment('파일 업로드 사용');
$table->unsignedInteger('max_file_size')->nullable()->default(10)->comment('최대 파일 크기 (MB)');
$table->unsignedInteger('max_file_count')->nullable()->default(5)->comment('최대 파일 개수');
$table->text('allowed_extensions')->nullable()->comment('허용 확장자 배열');
$table->string('comment_order', 4)->default('ASC')->comment('댓글 정렬 순서 (ASC: 오름차순, DESC: 내림차순)');
$table->unsignedSmallInteger('max_comment_depth')->default(10)->comment('대댓글 최대 깊이 (1~10)');
$table->boolean('notify_author')->default(true)->comment('작성자 이메일 알림 (댓글, 대댓글, 답변글, 관리자 처리 시)');
$table->boolean('notify_admin_on_post')->default(true)->comment('관리자 이메일 알림 (게시글 등록 시)');
$table->text('notify_author_channels')->nullable()->comment('작성자 알림 채널 (배열, 예: ["mail", "database"])');
$table->text('notify_admin_on_post_channels')->nullable()->comment('관리자 알림 채널 (배열, 예: ["mail", "database"])');
$table->text('blocked_keywords')->nullable()->comment('금지어 목록 (배열)');
$table->unsignedBigInteger('created_by')->nullable()->index()->comment('생성자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->timestamps();
$table->foreign('created_by')->references('id')->on('users')->nullOnDelete();
$table->foreign('updated_by')->references('id')->on('users')->nullOnDelete();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('boards', function (Blueprint $table) {
$table->comment('게시판 설정 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('boards')) {
Schema::dropIfExists('boards');
}
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 사용자 알림 설정
Schema::create('board_user_notification_settings', function (Blueprint $table) {
$table->id()->comment('알림 설정 ID');
$table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
$table->boolean('notify_post_complete')->default(false)->comment('게시글 완료 알림');
$table->boolean('notify_post_reply')->default(false)->comment('게시글 답변 알림');
$table->boolean('notify_comment')->default(false)->comment('댓글 알림');
$table->boolean('notify_reply_comment')->default(false)->comment('답글 알림');
$table->timestamps();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('board_user_notification_settings', function (Blueprint $table) {
$table->comment('게시판 사용자 알림 설정');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('board_user_notification_settings')) {
Schema::dropIfExists('board_user_notification_settings');
}
}
};
@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* MySQL/MariaDB 전용: PARTITION BY LIST (board_id) 파티션 적용
* 파티션 테이블에는 FK 설정 불가 — 앱 계층에서 무결성 처리
*/
public function up(): void
{
$prefix = DB::getTablePrefix();
// 게시글
Schema::create('board_posts', function (Blueprint $table) {
$table->unsignedBigInteger('id', true)->comment('게시글 ID');
$table->unsignedBigInteger('board_id')->comment('게시판 ID (파티션 키)');
$table->primary(['id', 'board_id']);
$table->string('category', 50)->nullable()->index()->comment('분류');
$table->string('title', 200)->comment('제목');
$table->longText('content')->comment('내용');
$table->string('content_mode', 10)->default('text')->comment('콘텐츠 모드: text(텍스트), html(HTML)');
$table->unsignedBigInteger('user_id')->nullable()->index()->comment('회원 ID');
$table->string('author_name', 50)->nullable()->comment('작성자명 (비회원)');
$table->string('password')->nullable()->comment('비밀번호 (비회원, bcrypt)');
$table->string('ip_address', 45)->comment('IP 주소');
$table->boolean('is_notice')->default(false)->comment('공지사항 여부 (1: 공지, 0: 일반)');
$table->boolean('is_secret')->default(false)->comment('비밀글 여부 (1: 비밀, 0: 공개)');
$table->enum('status', ['published', 'blinded', 'deleted'])->default('published')->comment('게시글 상태 (published: 게시됨, blinded: 블라인드 처리됨, deleted: 삭제됨)');
$table->enum('trigger_type', ['report', 'admin', 'system', 'auto_hide', 'user'])->default('admin')->comment('조치 주체 (report: 신고, admin: 관리자, system: 시스템, auto_hide: 자동 블라인드, user: 사용자 직접 삭제)');
$table->text('action_logs')->nullable()->comment('작업 이력 배열 (JSON)');
$table->unsignedInteger('view_count')->default(0)->index()->comment('조회수');
$table->unsignedBigInteger('parent_id')->nullable()->index()->comment('부모 게시글 ID (답글용)');
$table->unsignedInteger('depth')->default(0)->comment('답글 깊이 (0: 원글, 1+: 답글)');
$table->timestamps();
$table->softDeletes();
$table->index('deleted_at');
$table->index(['board_id', 'created_at'], 'idx_board_created');
$table->index(['board_id', 'status'], 'idx_board_status');
$table->index(['board_id', 'is_notice'], 'idx_board_notice');
$table->index(['user_id', 'created_at'], 'idx_user_created');
$table->index(['user_id', 'view_count'], 'idx_user_views');
});
DB::statement("ALTER TABLE {$prefix}board_posts PARTITION BY LIST (board_id) (PARTITION p_default VALUES IN (0))");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('board_posts')) {
Schema::dropIfExists('board_posts');
}
}
};
@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* MySQL/MariaDB 전용: PARTITION BY LIST (board_id) 파티션 적용
* 파티션 테이블에는 FK 설정 불가 — 앱 계층에서 무결성 처리
*/
public function up(): void
{
$prefix = DB::getTablePrefix();
// 댓글
Schema::create('board_comments', function (Blueprint $table) {
$table->unsignedBigInteger('id', true)->comment('댓글 ID');
$table->unsignedBigInteger('board_id')->comment('게시판 ID (파티션 키)');
$table->primary(['id', 'board_id']);
$table->unsignedBigInteger('post_id')->comment('게시글 ID');
$table->unsignedBigInteger('user_id')->nullable()->index()->comment('작성자 ID (회원)');
$table->unsignedBigInteger('parent_id')->nullable()->index()->comment('부모 댓글 ID (답글용)');
$table->string('author_name', 50)->nullable()->comment('작성자명 (비회원용)');
$table->string('password')->nullable()->comment('비밀번호 (비회원용, 해시 저장)');
$table->text('content')->comment('댓글 내용');
$table->boolean('is_secret')->default(false)->comment('비밀 댓글 여부 (1: 비밀, 0: 공개)');
$table->enum('status', ['published', 'blinded', 'deleted'])->default('published')->comment('댓글 상태 (published: 게시됨, blinded: 블라인드 처리됨, deleted: 삭제됨)');
$table->enum('trigger_type', ['report', 'admin', 'system', 'auto_hide', 'user'])->default('admin')->comment('조치 주체 (report: 신고, admin: 관리자, system: 시스템, auto_hide: 자동 블라인드, user: 사용자 직접 삭제)');
$table->text('action_logs')->nullable()->comment('작업 이력 배열 (JSON)');
$table->unsignedTinyInteger('depth')->default(0)->comment('댓글 깊이 (0: 댓글, 1: 답글)');
$table->string('ip_address', 45)->nullable()->comment('작성자 IP');
$table->timestamps();
$table->softDeletes();
$table->index('deleted_at');
$table->index(['board_id', 'post_id'], 'idx_board_post');
$table->index(['board_id', 'status'], 'idx_board_status');
$table->index(['board_id', 'created_at'], 'idx_board_created');
$table->index(['user_id', 'post_id'], 'idx_user_post');
$table->index(['user_id', 'created_at'], 'idx_user_created_at');
});
DB::statement("ALTER TABLE {$prefix}board_comments PARTITION BY LIST (board_id) (PARTITION p_default VALUES IN (0))");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('board_comments')) {
Schema::dropIfExists('board_comments');
}
}
};
@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* MySQL/MariaDB 전용: PARTITION BY LIST (board_id) 파티션 적용
* 파티션 테이블에는 FK 설정 불가 — 앱 계층에서 무결성 처리
*/
public function up(): void
{
$prefix = DB::getTablePrefix();
// 첨부파일
Schema::create('board_attachments', function (Blueprint $table) {
$table->unsignedBigInteger('id', true)->comment('첨부파일 ID');
$table->unsignedBigInteger('board_id')->default(0)->comment('게시판 ID (파티션 키, 0: 임시 업로드)');
$table->primary(['id', 'board_id']);
$table->unsignedBigInteger('post_id')->nullable()->comment('게시글 ID (임시 업로드 시 null)');
$table->string('temp_key', 64)->nullable()->comment('임시 업로드 키 (게시글 저장 후 null)');
$table->string('hash', 12)->comment('URL용 고유 해시 (12자)');
$table->string('original_filename', 255)->comment('원본 파일명');
$table->string('stored_filename', 255)->comment('저장된 파일명 (UUID 기반)');
$table->string('disk', 50)->default('local')->comment('스토리지 디스크 (local, s3 등)');
$table->string('path', 500)->comment('저장 경로 (디스크 기준 상대 경로)');
$table->string('mime_type', 100)->comment('MIME 타입 (예: image/jpeg, application/pdf)');
$table->unsignedBigInteger('size')->comment('파일 크기 (바이트)');
$table->string('collection', 100)->default('default')->comment('첨부파일 컬렉션/그룹명');
$table->unsignedInteger('order')->default(0)->comment('정렬 순서');
$table->text('meta')->nullable()->comment('추가 메타데이터 JSON (썸네일 경로, 이미지 크기 등)');
$table->unsignedBigInteger('created_by')->nullable()->comment('업로더 ID');
$table->timestamps();
$table->softDeletes();
$table->unique(['board_id', 'hash'], 'uq_board_hash');
$table->index(['board_id', 'post_id'], 'idx_board_post');
$table->index(['board_id', 'temp_key'], 'idx_board_temp_key');
$table->index('collection', 'idx_collection');
$table->index('deleted_at', 'idx_deleted_at');
$table->index('hash', 'idx_hash');
});
DB::statement("ALTER TABLE {$prefix}board_attachments PARTITION BY LIST (board_id) (PARTITION p_default VALUES IN (0))");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('board_attachments')) {
Schema::dropIfExists('board_attachments');
}
}
};
@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 신고 케이스
Schema::create('boards_reports', function (Blueprint $table) {
$table->id()->comment('신고 케이스 ID');
$table->unsignedBigInteger('board_id')->nullable()->comment('게시판 ID (게시판 삭제 시 NULL)');
$table->string('target_type', 20)->comment('신고 대상 타입 (post, comment)');
$table->unsignedBigInteger('target_id')->comment('신고 대상 ID (동적 테이블의 ID)');
$table->unsignedBigInteger('author_id')->nullable()->comment('작성자 ID (작성자 삭제 시 NULL)');
$table->string('status', 20)->default('pending')->comment('신고 상태 (pending, review, rejected, suspended)');
$table->unsignedBigInteger('processed_by')->nullable()->comment('처리자 ID');
$table->timestamp('processed_at')->nullable()->comment('처리 일시');
$table->mediumText('process_histories')->nullable()->comment('처리 이력 배열 [{type, admin_id, admin_name, reason, reporter_count, created_at}]');
$table->text('metadata')->nullable()->comment('메타데이터 (IP, User Agent 등)');
$table->timestamp('last_reported_at')->nullable()->comment('마지막 신고 일시 — 목록 정렬용');
$table->timestamp('last_activated_at')->nullable()->comment('케이스 재활성 일시 — 자동 블라인드 카운트 기준 (재신고 시 갱신)');
$table->timestamps();
$table->softDeletes();
$table->unique(['board_id', 'target_type', 'target_id'], 'unique_report_case');
$table->index('board_id', 'idx_board');
$table->index(['board_id', 'target_type', 'target_id'], 'idx_target');
$table->index('author_id', 'idx_author');
$table->index('status', 'idx_status');
$table->index('last_reported_at', 'idx_last_reported');
$table->foreign('board_id')->references('id')->on('boards')->nullOnDelete();
$table->foreign('author_id')->references('id')->on('users')->nullOnDelete();
$table->foreign('processed_by')->references('id')->on('users')->nullOnDelete();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('boards_reports', function (Blueprint $table) {
$table->comment('게시판 신고 케이스 (게시글/댓글당 1행)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('boards_reports')) {
Schema::dropIfExists('boards_reports');
}
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 신고 로그
Schema::create('boards_report_logs', function (Blueprint $table) {
$table->id()->comment('신고 로그 ID');
$table->foreignId('report_id')->constrained('boards_reports')->cascadeOnDelete()->comment('케이스 ID (boards_reports.id)');
$table->unsignedBigInteger('reporter_id')->nullable()->comment('신고자 ID (탈퇴 시 NULL)');
$table->mediumText('snapshot')->nullable()->comment('신고 당시 게시물 스냅샷 (JSON: board_name, title, content, content_mode, author_name)');
$table->string('reason_type', 50)->nullable()->comment('신고 사유 유형 (abuse, hate_speech, spam, copyright, privacy, misinformation, sexual, violence, other)');
$table->text('reason_detail')->nullable()->comment('신고 상세 사유 (신고자 입력)');
$table->text('metadata')->nullable()->comment('메타데이터 (IP, User Agent 등)');
$table->timestamps();
$table->unique(['report_id', 'reporter_id'], 'unique_report_log');
$table->index('report_id', 'idx_report');
$table->index('reporter_id', 'idx_reporter');
$table->foreign('reporter_id')->references('id')->on('users')->nullOnDelete();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('boards_report_logs', function (Blueprint $table) {
$table->comment('게시판 신고 로그 (신고자별 기록)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('boards_report_logs')) {
Schema::dropIfExists('boards_report_logs');
}
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('board_mail_templates', function (Blueprint $table) {
$table->id()->comment('ID');
$table->string('type', 100)->unique()->comment('템플릿 유형 (new_comment, reply_comment 등)');
$table->text('subject')->comment('다국어 제목 ({"ko": "...", "en": "..."})');
$table->mediumText('body')->comment('다국어 HTML 본문 ({"ko": "...", "en": "..."})');
$table->text('variables')->nullable()->comment('사용 가능 변수 메타데이터 ([{key, description}])');
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->boolean('is_default')->default(true)->comment('시더 생성 항목 여부');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable('board_mail_templates')) {
Schema::dropIfExists('board_mail_templates');
}
}
};
@@ -0,0 +1,45 @@
<?php
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* boards 테이블에 FULLTEXT 인덱스 추가
*
* name 컬럼에 대해 MATCH...AGAINST 검색을 지원합니다.
* FULLTEXT 미지원 DBMS에서는 자동 스킵됩니다.
*/
return new class extends Migration
{
/**
* 마이그레이션을 실행합니다.
*
* @return void
*/
public function up(): void
{
DatabaseFulltextEngine::addFulltextIndex('boards', 'ft_boards_name', 'name');
}
/**
* 마이그레이션을 롤백합니다.
*
* @return void
*/
public function down(): void
{
if (! Schema::hasTable('boards')) {
return;
}
$indexes = array_column(Schema::getIndexes('boards'), 'name');
Schema::table('boards', function (Blueprint $table) use ($indexes) {
if (in_array('ft_boards_name', $indexes)) {
$table->dropIndex('ft_boards_name');
}
});
}
};
@@ -0,0 +1,45 @@
<?php
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* boards_report_logs 테이블에 FULLTEXT 인덱스 추가
*
* snapshot 컬럼에 대해 MATCH...AGAINST 검색을 지원합니다.
* FULLTEXT 미지원 DBMS에서는 자동 스킵됩니다.
*/
return new class extends Migration
{
/**
* 마이그레이션을 실행합니다.
*
* @return void
*/
public function up(): void
{
DatabaseFulltextEngine::addFulltextIndex('boards_report_logs', 'ft_boards_report_logs_snapshot', 'snapshot');
}
/**
* 마이그레이션을 롤백합니다.
*
* @return void
*/
public function down(): void
{
if (! Schema::hasTable('boards_report_logs')) {
return;
}
$indexes = array_column(Schema::getIndexes('boards_report_logs'), 'name');
Schema::table('boards_report_logs', function (Blueprint $table) use ($indexes) {
if (in_array('ft_boards_report_logs_snapshot', $indexes)) {
$table->dropIndex('ft_boards_report_logs_snapshot');
}
});
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* board_posts 테이블 인덱스 추가. (LIST 파티션 테이블)
*
* - [board_id, author_name]: 비회원 작성자 검색 (파티션 프루닝)
* - [board_id, status, created_at]: 게시판별 상태+날짜 필터 (가장 빈번한 패턴)
*
* 참고: [board_id, status, created_at]는 기존 [board_id, status] 인덱스를 포함합니다.
* 기존 인덱스 제거는 별도 후속 작업으로 처리합니다.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('board_posts', function (Blueprint $table) {
$table->index(['board_id', 'author_name'], 'idx_board_posts_board_author');
$table->index(['board_id', 'status', 'created_at'], 'idx_board_posts_board_status_created');
});
}
public function down(): void
{
if (! Schema::hasTable('board_posts')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('board_posts'), 'name');
Schema::table('board_posts', function (Blueprint $table) use ($existingIndexes) {
$indexes = [
'idx_board_posts_board_author',
'idx_board_posts_board_status_created',
];
foreach ($indexes as $index) {
if (in_array($index, $existingIndexes)) {
$table->dropIndex($index);
}
}
});
}
};
@@ -0,0 +1,760 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders;
use App\Enums\ActivityLogType;
use App\Models\ActivityLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\BoardType;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Models\Report;
/**
* 게시판 활동 로그 샘플 시더
*
* 리소스 중심 rand(1,50) 방식으로 ActivityLog를 생성합니다.
* 각 리소스마다 rand(1,50)개의 랜덤 액션 로그를 생성합니다.
*/
class ActivityLogSampleSeeder extends Seeder
{
/** @var string 다국어 키 접두사 */
private const PREFIX = 'sirsoft-board::activity_log.description.';
/** @var array<string> 샘플 IP 목록 */
private const IPS = ['192.168.1.10', '10.0.0.5', '172.16.0.1', '192.168.0.100', '10.10.10.1'];
/** @var array<string> 샘플 User-Agent 목록 */
private const USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/125.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15',
'Mozilla/5.0 (X11; Linux x86_64) Firefox/126.0',
];
/**
* 시더를 실행합니다.
*/
public function run(): void
{
$admins = User::whereHas('roles', fn ($q) => $q->where('identifier', 'admin'))->get();
if ($admins->isEmpty()) {
$this->command->warn('관리자 사용자가 없어 게시판 활동 로그 시더를 건너뜁니다.');
return;
}
$users = User::whereDoesntHave('roles', fn ($q) => $q->where('identifier', 'admin'))->get();
if ($users->isEmpty()) {
$users = $admins;
}
// 기존 게시판 활동 로그 삭제
$deleted = ActivityLog::where('description_key', 'like', 'sirsoft-board::%')->delete();
if ($deleted > 0) {
$this->command->info("기존 게시판 활동 로그 {$deleted}건 삭제.");
}
$count = 0;
$count += $this->seedBoardLogs($admins);
$count += $this->seedBoardTypeLogs($admins);
$count += $this->seedPostLogs($admins, $users);
$count += $this->seedCommentLogs($admins, $users);
$count += $this->seedAttachmentLogs($admins, $users);
$count += $this->seedReportLogs($admins);
$this->command->info("게시판 활동 로그 {$count}건 생성 완료.");
}
/**
* 게시판 활동 로그 (리소스별 rand(1,50)건)
*
* @param Collection $admins 관리자 컬렉션
* @return int 생성된 로그 수
*/
private function seedBoardLogs(Collection $admins): int
{
$boards = Board::get();
if ($boards->isEmpty()) {
$this->command->warn('게시판 데이터가 없어 게시판 활동 로그를 건너뜁니다.');
return 0;
}
$morphType = (new Board)->getMorphClass();
$actions = [
[
'action' => 'board.create',
'key' => 'board_create',
'loggable' => true,
'params' => fn (Board $board) => ['board_name' => $this->getLocalizedName($board->name)],
'changes' => null,
'properties' => null,
],
[
'action' => 'board.update',
'key' => 'board_update',
'loggable' => true,
'params' => fn (Board $board) => ['board_name' => $this->getLocalizedName($board->name)],
'changes' => fn (Board $board) => [
['field' => 'name', 'label_key' => 'sirsoft-board::activity_log.fields.name', 'old' => $this->getLocalizedName($board->name).' (수정 전)', 'new' => $this->getLocalizedName($board->name), 'type' => 'text'],
],
'properties' => null,
],
[
'action' => 'board.show',
'key' => 'board_show',
'loggable' => true,
'params' => fn (Board $board) => ['board_name' => $this->getLocalizedName($board->name)],
'changes' => null,
'properties' => null,
],
[
'action' => 'board.delete',
'key' => 'board_delete',
'loggable' => false,
'params' => fn (Board $board) => ['board_name' => $this->getLocalizedName($board->name)],
'changes' => null,
'properties' => fn (Board $board) => ['deleted_id' => $board->id, 'slug' => $board->slug, 'name' => $this->getLocalizedName($board->name)],
],
[
'action' => 'board.add_to_menu',
'key' => 'board_add_to_menu',
'loggable' => true,
'params' => fn (Board $board) => ['board_name' => $this->getLocalizedName($board->name)],
'changes' => null,
'properties' => null,
],
[
'action' => 'board_settings.bulk_apply',
'key' => 'board_settings_bulk_apply',
'loggable' => true,
'params' => fn (Board $board) => ['board_name' => $this->getLocalizedName($board->name)],
'changes' => fn (Board $board) => [
['field' => 'per_page', 'label_key' => 'sirsoft-board::activity_log.fields.per_page', 'old' => 10, 'new' => 20, 'type' => 'number'],
['field' => 'use_comment', 'label_key' => 'sirsoft-board::activity_log.fields.use_comment', 'old' => false, 'new' => true, 'type' => 'boolean'],
],
'properties' => null,
],
];
return $this->generateResourceLogs($boards, $admins, ActivityLogType::Admin, $morphType, $actions);
}
/**
* 게시판 유형 활동 로그 (리소스별 rand(1,50)건)
*
* @param Collection $admins 관리자 컬렉션
* @return int 생성된 로그 수
*/
private function seedBoardTypeLogs(Collection $admins): int
{
$types = BoardType::get();
if ($types->isEmpty()) {
$this->command->warn('게시판 유형 데이터가 없어 게시판 유형 활동 로그를 건너뜁니다.');
return 0;
}
$morphType = (new BoardType)->getMorphClass();
$actions = [
[
'action' => 'board_type.create',
'key' => 'board_type_create',
'loggable' => true,
'params' => fn (BoardType $type) => ['type_name' => $this->getLocalizedName($type->name)],
'changes' => null,
'properties' => null,
],
[
'action' => 'board_type.update',
'key' => 'board_type_update',
'loggable' => true,
'params' => fn (BoardType $type) => ['type_name' => $this->getLocalizedName($type->name)],
'changes' => fn (BoardType $type) => [
['field' => 'name', 'label_key' => 'sirsoft-board::activity_log.fields.name', 'old' => $this->getLocalizedName($type->name).' (수정 전)', 'new' => $this->getLocalizedName($type->name), 'type' => 'text'],
],
'properties' => null,
],
[
'action' => 'board_type.show',
'key' => 'board_type_show',
'loggable' => true,
'params' => fn (BoardType $type) => ['type_name' => $this->getLocalizedName($type->name)],
'changes' => null,
'properties' => null,
],
[
'action' => 'board_type.delete',
'key' => 'board_type_delete',
'loggable' => false,
'params' => fn (BoardType $type) => ['type_name' => $this->getLocalizedName($type->name)],
'changes' => null,
'properties' => fn (BoardType $type) => ['deleted_id' => $type->id, 'slug' => $type->slug],
],
];
return $this->generateResourceLogs($types, $admins, ActivityLogType::Admin, $morphType, $actions);
}
/**
* 게시글 활동 로그 (리소스별 rand(1,50)건)
*
* @param Collection $admins 관리자 컬렉션
* @param Collection $users 일반 사용자 컬렉션
* @return int 생성된 로그 수
*/
private function seedPostLogs(Collection $admins, Collection $users): int
{
$posts = Post::with('board')->get();
if ($posts->isEmpty()) {
$this->command->warn('게시글 데이터가 없어 게시글 활동 로그를 건너뜁니다.');
return 0;
}
$morphType = (new Post)->getMorphClass();
$adminActions = [
[
'action' => 'post.create',
'key' => 'post_create',
'logType' => ActivityLogType::Admin,
'loggable' => true,
'params' => fn (Post $post) => ['title' => $post->title, 'board_name' => $post->board ? $this->getLocalizedName($post->board->name) : '알 수 없음'],
'changes' => null,
'properties' => null,
],
[
'action' => 'post.update',
'key' => 'post_update',
'logType' => ActivityLogType::Admin,
'loggable' => true,
'params' => fn (Post $post) => ['title' => $post->title, 'board_name' => $post->board ? $this->getLocalizedName($post->board->name) : '알 수 없음'],
'changes' => fn (Post $post) => [
['field' => 'title', 'label_key' => 'sirsoft-board::activity_log.fields.title', 'old' => $post->title.' (수정 전)', 'new' => $post->title, 'type' => 'text'],
['field' => 'content', 'label_key' => 'sirsoft-board::activity_log.fields.content', 'old' => '이전 내용...', 'new' => $post->content ?? '수정된 내용', 'type' => 'text'],
],
'properties' => null,
],
[
'action' => 'post.delete',
'key' => 'post_delete',
'logType' => ActivityLogType::Admin,
'loggable' => false,
'params' => fn (Post $post) => ['board_name' => $post->board ? $this->getLocalizedName($post->board->name) : '알 수 없음', 'post_id' => $post->id],
'changes' => null,
'properties' => fn (Post $post) => ['deleted_id' => $post->id, 'board_id' => $post->board_id, 'title' => $post->title, 'user_id' => $post->user_id],
],
[
'action' => 'post.blind',
'key' => 'post_blind',
'logType' => ActivityLogType::Admin,
'loggable' => true,
'params' => fn (Post $post) => ['board_name' => $post->board ? $this->getLocalizedName($post->board->name) : '알 수 없음', 'post_id' => $post->id],
'changes' => fn (Post $post) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => $post->status?->value ?? PostStatus::Published->value, 'new' => PostStatus::Blinded->value, 'type' => 'enum',
'old_label_key' => PostStatus::Published->labelKey(), 'new_label_key' => PostStatus::Blinded->labelKey()],
],
'properties' => null,
],
[
'action' => 'post.restore',
'key' => 'post_restore',
'logType' => ActivityLogType::Admin,
'loggable' => true,
'params' => fn (Post $post) => ['board_name' => $post->board ? $this->getLocalizedName($post->board->name) : '알 수 없음', 'post_id' => $post->id],
'changes' => fn (Post $post) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => PostStatus::Blinded->value, 'new' => PostStatus::Published->value, 'type' => 'enum',
'old_label_key' => PostStatus::Blinded->labelKey(), 'new_label_key' => PostStatus::Published->labelKey()],
],
'properties' => null,
],
];
$userActions = [
[
'action' => 'post.create',
'key' => 'post_create',
'logType' => ActivityLogType::User,
'loggable' => true,
'params' => fn (Post $post) => ['title' => $post->title, 'board_name' => $post->board ? $this->getLocalizedName($post->board->name) : '알 수 없음'],
'changes' => null,
'properties' => null,
'userIdResolver' => fn (Post $post, Collection $actors) => $post->user_id ?? $actors->random()->id,
],
[
'action' => 'post.update',
'key' => 'post_update',
'logType' => ActivityLogType::User,
'loggable' => true,
'params' => fn (Post $post) => ['title' => $post->title, 'board_name' => $post->board ? $this->getLocalizedName($post->board->name) : '알 수 없음'],
'changes' => fn (Post $post) => [
['field' => 'title', 'label_key' => 'sirsoft-board::activity_log.fields.title', 'old' => $post->title.' (수정 전)', 'new' => $post->title, 'type' => 'text'],
],
'properties' => null,
'userIdResolver' => fn (Post $post, Collection $actors) => $post->user_id ?? $actors->random()->id,
],
];
$allActions = array_merge($adminActions, $userActions);
$count = $this->generateResourceLogsWithMixedTypes($posts, $admins, $users, $morphType, $allActions);
return $count;
}
/**
* 댓글 활동 로그 (리소스별 rand(1,50)건)
*
* @param Collection $admins 관리자 컬렉션
* @param Collection $users 일반 사용자 컬렉션
* @return int 생성된 로그 수
*/
private function seedCommentLogs(Collection $admins, Collection $users): int
{
$comments = Comment::with('post.board')->get();
if ($comments->isEmpty()) {
$this->command->warn('댓글 데이터가 없어 댓글 활동 로그를 건너뜁니다.');
return 0;
}
$morphType = (new Comment)->getMorphClass();
$adminActions = [
[
'action' => 'comment.create',
'key' => 'comment_create',
'logType' => ActivityLogType::Admin,
'loggable' => true,
'params' => fn (Comment $comment) => ['board_name' => $comment->post?->board ? $this->getLocalizedName($comment->post->board->name) : '알 수 없음', 'post_id' => $comment->post_id],
'changes' => null,
'properties' => null,
],
[
'action' => 'comment.delete',
'key' => 'comment_delete',
'logType' => ActivityLogType::Admin,
'loggable' => false,
'params' => fn (Comment $comment) => ['comment_id' => $comment->id],
'changes' => null,
'properties' => fn (Comment $comment) => ['deleted_id' => $comment->id, 'post_id' => $comment->post_id, 'user_id' => $comment->user_id],
],
[
'action' => 'comment.blind',
'key' => 'comment_blind',
'logType' => ActivityLogType::Admin,
'loggable' => true,
'params' => fn (Comment $comment) => ['comment_id' => $comment->id],
'changes' => fn (Comment $comment) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => $comment->status?->value ?? PostStatus::Published->value, 'new' => PostStatus::Blinded->value, 'type' => 'enum',
'old_label_key' => PostStatus::Published->labelKey(), 'new_label_key' => PostStatus::Blinded->labelKey()],
],
'properties' => null,
],
[
'action' => 'comment.restore',
'key' => 'comment_restore',
'logType' => ActivityLogType::Admin,
'loggable' => true,
'params' => fn (Comment $comment) => ['comment_id' => $comment->id],
'changes' => fn (Comment $comment) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => PostStatus::Blinded->value, 'new' => PostStatus::Published->value, 'type' => 'enum',
'old_label_key' => PostStatus::Blinded->labelKey(), 'new_label_key' => PostStatus::Published->labelKey()],
],
'properties' => null,
],
];
$userActions = [
[
'action' => 'comment.create',
'key' => 'comment_create',
'logType' => ActivityLogType::User,
'loggable' => true,
'params' => fn (Comment $comment) => ['board_name' => $comment->post?->board ? $this->getLocalizedName($comment->post->board->name) : '알 수 없음', 'post_id' => $comment->post_id],
'changes' => null,
'properties' => null,
'userIdResolver' => fn (Comment $comment, Collection $actors) => $comment->user_id ?? $actors->random()->id,
],
[
'action' => 'comment.update',
'key' => 'comment_update',
'logType' => ActivityLogType::User,
'loggable' => true,
'params' => fn (Comment $comment) => ['comment_id' => $comment->id],
'changes' => fn (Comment $comment) => [
['field' => 'content', 'label_key' => 'sirsoft-board::activity_log.fields.content', 'old' => ($comment->content ?? '이전 댓글').' (수정 전)', 'new' => $comment->content ?? '수정된 댓글', 'type' => 'text'],
],
'properties' => null,
'userIdResolver' => fn (Comment $comment, Collection $actors) => $comment->user_id ?? $actors->random()->id,
],
];
$allActions = array_merge($adminActions, $userActions);
return $this->generateResourceLogsWithMixedTypes($comments, $admins, $users, $morphType, $allActions);
}
/**
* 첨부파일 활동 로그 (리소스별 rand(1,50)건)
*
* @param Collection $admins 관리자 컬렉션
* @param Collection $users 일반 사용자 컬렉션
* @return int 생성된 로그 수
*/
private function seedAttachmentLogs(Collection $admins, Collection $users): int
{
$attachments = Attachment::get();
if ($attachments->isEmpty()) {
$this->command->warn('첨부파일 데이터가 없어 첨부파일 활동 로그를 건너뜁니다.');
return 0;
}
$morphType = (new Attachment)->getMorphClass();
$allActions = [
[
'action' => 'attachment.upload',
'key' => 'board_attachment_upload',
'logType' => null,
'loggable' => true,
'params' => fn (Attachment $attachment) => ['post_id' => $attachment->attachable_id],
'changes' => null,
'properties' => null,
'userIdResolver' => fn (Attachment $attachment, Collection $actors) => $attachment->created_by ?? $actors->random()->id,
],
[
'action' => 'attachment.delete',
'key' => 'board_attachment_delete',
'logType' => ActivityLogType::Admin,
'loggable' => false,
'params' => fn (Attachment $attachment) => ['post_id' => $attachment->attachable_id],
'changes' => null,
'properties' => fn (Attachment $attachment) => ['deleted_id' => $attachment->id, 'original_filename' => $attachment->original_filename, 'mime_type' => $attachment->mime_type, 'size' => $attachment->size],
],
];
return $this->generateResourceLogsWithMixedTypes($attachments, $admins, $users, $morphType, $allActions);
}
/**
* 신고 활동 로그 (리소스별 rand(1,50)건)
*
* @param Collection $admins 관리자 컬렉션
* @return int 생성된 로그 수
*/
private function seedReportLogs(Collection $admins): int
{
$reports = Report::get();
if ($reports->isEmpty()) {
$this->command->warn('신고 데이터가 없어 신고 활동 로그를 건너뜁니다.');
return 0;
}
$morphType = (new Report)->getMorphClass();
$actions = [
[
'action' => 'report.create',
'key' => 'report_create',
'loggable' => true,
'params' => fn (Report $report) => ['report_id' => $report->id],
'changes' => null,
'properties' => null,
],
[
'action' => 'report.update_status',
'key' => 'report_update_status',
'loggable' => true,
'params' => fn (Report $report) => ['report_id' => $report->id],
'changes' => fn (Report $report) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => $this->pickDifferentEnum(ReportStatus::class, $report->status), 'new' => $report->status?->value ?? ReportStatus::Pending->value, 'type' => 'enum'],
],
'properties' => null,
],
[
'action' => 'report.delete',
'key' => 'report_delete',
'loggable' => false,
'params' => fn (Report $report) => ['report_id' => $report->id],
'changes' => null,
'properties' => fn (Report $report) => ['deleted_id' => $report->id, 'board_id' => $report->board_id],
],
[
'action' => 'report.blind_content',
'key' => 'report_blind_content',
'loggable' => true,
'params' => fn (Report $report) => ['report_id' => $report->id],
'changes' => fn (Report $report) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => ReportStatus::Pending->value, 'new' => ReportStatus::Suspended->value, 'type' => 'enum',
'old_label_key' => ReportStatus::Pending->labelKey(), 'new_label_key' => ReportStatus::Suspended->labelKey()],
],
'properties' => null,
],
[
'action' => 'report.restore_content',
'key' => 'report_restore_content',
'loggable' => true,
'params' => fn (Report $report) => ['report_id' => $report->id],
'changes' => fn (Report $report) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => ReportStatus::Suspended->value, 'new' => ReportStatus::Rejected->value, 'type' => 'enum',
'old_label_key' => ReportStatus::Suspended->labelKey(), 'new_label_key' => ReportStatus::Rejected->labelKey()],
],
'properties' => null,
],
[
'action' => 'report.delete_content',
'key' => 'report_delete_content',
'loggable' => true,
'params' => fn (Report $report) => ['report_id' => $report->id],
'changes' => fn (Report $report) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => ReportStatus::Pending->value, 'new' => ReportStatus::Deleted->value, 'type' => 'enum',
'old_label_key' => ReportStatus::Pending->labelKey(), 'new_label_key' => ReportStatus::Deleted->labelKey()],
],
'properties' => null,
],
[
'action' => 'report.bulk_update_status',
'key' => 'report_bulk_update_status',
'loggable' => true,
'params' => fn (Report $report) => ['count' => rand(2, 8)],
'changes' => fn (Report $report) => [
['field' => 'status', 'label_key' => 'sirsoft-board::activity_log.fields.status', 'old' => $this->pickDifferentEnum(ReportStatus::class, $report->status), 'new' => $report->status?->value ?? ReportStatus::Pending->value, 'type' => 'enum'],
],
'properties' => null,
],
];
return $this->generateResourceLogs($reports, $admins, ActivityLogType::Admin, $morphType, $actions);
}
/**
* 리소스 중심 랜덤 로그를 생성합니다.
*
* 각 리소스마다 rand(1,50)개의 랜덤 액션을 선택(중복 허용)하여 로그를 생성합니다.
*
* @param Collection $resources 리소스 컬렉션
* @param Collection $actors 액터(사용자) 컬렉션
* @param ActivityLogType $logType 로그 유형
* @param string $morphType 모델 morph 타입
* @param array $actions 액션 템플릿 배열
* @return int 생성된 로그 수
*/
private function generateResourceLogs(
Collection $resources,
Collection $actors,
ActivityLogType $logType,
string $morphType,
array $actions,
): int {
$count = 0;
foreach ($resources as $resource) {
$logCount = rand(1, 50);
for ($i = 0; $i < $logCount; $i++) {
$action = $actions[array_rand($actions)];
$loggableType = $action['loggable'] ? $morphType : null;
$loggableId = $action['loggable'] ? $resource->id : null;
$params = ($action['params'])($resource);
$changes = $action['changes'] ? ($action['changes'])($resource) : null;
$properties = $action['properties'] ? ($action['properties'])($resource) : null;
$this->createLog(
$logType,
$loggableType,
$loggableId,
$actors->random()->id,
$action['action'],
self::PREFIX.$action['key'],
$params,
$changes,
$properties,
);
$count++;
}
}
return $count;
}
/**
* 혼합 로그 유형(Admin/User)을 가진 리소스 중심 랜덤 로그를 생성합니다.
*
* 각 리소스마다 rand(1,50)개의 랜덤 액션을 선택(중복 허용)하여 로그를 생성합니다.
* 각 액션 템플릿에 logType 필드가 있으면 해당 유형을 사용합니다.
*
* @param Collection $resources 리소스 컬렉션
* @param Collection $admins 관리자 컬렉션
* @param Collection $users 일반 사용자 컬렉션
* @param string $morphType 모델 morph 타입
* @param array $actions 액션 템플릿 배열 (logType, userIdResolver 포함 가능)
* @return int 생성된 로그 수
*/
private function generateResourceLogsWithMixedTypes(
Collection $resources,
Collection $admins,
Collection $users,
string $morphType,
array $actions,
): int {
$count = 0;
foreach ($resources as $resource) {
$logCount = rand(1, 50);
for ($i = 0; $i < $logCount; $i++) {
$action = $actions[array_rand($actions)];
// logType이 null이면 랜덤으로 Admin/User 결정 (attachment.upload 패턴)
$logType = $action['logType'] ?? (rand(0, 1) ? ActivityLogType::Admin : ActivityLogType::User);
$loggableType = $action['loggable'] ? $morphType : null;
$loggableId = $action['loggable'] ? $resource->id : null;
// userIdResolver가 있으면 사용, 없으면 logType에 따라 결정
$actorPool = $logType === ActivityLogType::Admin ? $admins : $users;
if (isset($action['userIdResolver'])) {
$userId = ($action['userIdResolver'])($resource, $actorPool);
} else {
$userId = $actorPool->random()->id;
}
$params = ($action['params'])($resource);
$changes = $action['changes'] ? ($action['changes'])($resource) : null;
$properties = $action['properties'] ? ($action['properties'])($resource) : null;
$this->createLog(
$logType,
$loggableType,
$loggableId,
$userId,
$action['action'],
self::PREFIX.$action['key'],
$params,
$changes,
$properties,
);
$count++;
}
}
return $count;
}
/**
* ActivityLog 레코드를 생성합니다.
*
* @param ActivityLogType $logType 로그 유형
* @param string|null $loggableType 모델 morph 타입
* @param int|null $loggableId 모델 ID
* @param int $userId 사용자 ID
* @param string $action 액션명
* @param string $descriptionKey 다국어 키
* @param array $descriptionParams 다국어 파라미터
* @param array|null $changes 변경 이력
* @param array|null $properties 추가 속성
*/
private function createLog(
ActivityLogType $logType,
?string $loggableType,
?int $loggableId,
int $userId,
string $action,
string $descriptionKey,
array $descriptionParams,
?array $changes = null,
?array $properties = null,
): ActivityLog {
return ActivityLog::create([
'log_type' => $logType,
'loggable_type' => $loggableType,
'loggable_id' => $loggableId,
'user_id' => $userId,
'action' => $action,
'description_key' => $descriptionKey,
'description_params' => $descriptionParams,
'changes' => $changes,
'properties' => $properties,
'ip_address' => self::IPS[array_rand(self::IPS)],
'user_agent' => self::USER_AGENTS[array_rand(self::USER_AGENTS)],
'created_at' => Carbon::now()->subDays(rand(1, 30))->subHours(rand(0, 23))->subMinutes(rand(0, 59)),
]);
}
/**
* 다국어 name 배열에서 현재 로케일 값을 가져옵니다.
*
* @param mixed $name name 속성 (배열 또는 문자열)
*/
private function getLocalizedName(mixed $name): string
{
if (is_string($name)) {
return $name;
}
if (is_array($name)) {
$locale = app()->getLocale();
return $name[$locale] ?? $name['ko'] ?? $name[array_key_first($name)] ?? '';
}
return '';
}
/**
* Enum에서 현재 값과 다른 값을 반환합니다.
*
* @param class-string $enumClass Enum 클래스
* @param mixed $currentValue 현재 값
*/
private function pickDifferentEnum(string $enumClass, mixed $currentValue): string
{
$cases = $enumClass::cases();
$currentStr = $currentValue instanceof \BackedEnum ? $currentValue->value : (string) $currentValue;
$others = array_filter($cases, fn ($c) => $c->value !== $currentStr);
if (empty($others)) {
return $cases[0]->value;
}
return $others[array_rand($others)]->value;
}
/**
* 바이트 수를 사람이 읽기 쉬운 형식으로 변환합니다.
*
* @param int $bytes 바이트 수
*/
private function formatBytes(int $bytes): string
{
if ($bytes >= 1048576) {
return round($bytes / 1048576, 1).' MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 1).' KB';
}
return $bytes.' B';
}
}
@@ -0,0 +1,336 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Sirsoft\Board\Models\BoardMailTemplate;
/**
* 게시판 메일 템플릿을 시딩합니다 (5종).
*/
class BoardMailTemplateSeeder extends Seeder
{
/**
* 게시판 메일 템플릿을 시딩합니다.
*
* @return void
*/
public function run(): void
{
$this->command->info('게시판 메일 템플릿 시딩 시작...');
$templates = $this->getDefaultTemplates();
foreach ($templates as $data) {
BoardMailTemplate::updateOrCreate(
['type' => $data['type']],
[
'subject' => $data['subject'],
'body' => $data['body'],
'variables' => $data['variables'],
'is_active' => true,
'is_default' => true,
]
);
$this->command->info(" - {$data['type']} 템플릿 등록 완료");
}
$this->command->info('게시판 메일 템플릿 시딩 완료 (' . count($templates) . '종)');
}
/**
* 기본 템플릿 데이터를 반환합니다.
*
* @return array<int, array{type: string, subject: array, body: array, variables: array}>
*/
public function getDefaultTemplates(): array
{
return [
$this->newCommentTemplate(),
$this->replyCommentTemplate(),
$this->postReplyTemplate(),
$this->postActionTemplate(),
$this->newPostAdminTemplate(),
$this->reportReceivedAdminTemplate(),
$this->reportActionTemplate(),
];
}
/**
* 새 댓글 알림 템플릿을 반환합니다.
*
* @return array
*/
private function newCommentTemplate(): array
{
return [
'type' => 'new_comment',
'subject' => [
'ko' => '[{board_name}] 게시글에 새 댓글이 등록되었습니다',
'en' => '[{board_name}] New comment on your post',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
. '<p><strong>{board_name}</strong> 게시판의 게시글에 <strong>{comment_author}</strong>님이 댓글을 남겼습니다.</p>'
. '<blockquote style="border-left: 3px solid #cbd5e0; padding-left: 12px; color: #718096;">{comment_content}</blockquote>'
. $this->button('게시글 보기', '{post_url}')
. '<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
. '<p><strong>{comment_author}</strong> commented on your post in <strong>{board_name}</strong>.</p>'
. '<blockquote style="border-left: 3px solid #cbd5e0; padding-left: 12px; color: #718096;">{comment_content}</blockquote>'
. $this->button('View Post', '{post_url}')
. '<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'board_name', 'description' => '게시판 이름'],
['key' => 'post_title', 'description' => '게시글 제목'],
['key' => 'comment_author', 'description' => '댓글 작성자'],
['key' => 'comment_content', 'description' => '댓글 내용 (200자)'],
['key' => 'post_url', 'description' => '게시글 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
];
}
/**
* 대댓글 알림 템플릿을 반환합니다.
*
* @return array
*/
private function replyCommentTemplate(): array
{
return [
'type' => 'reply_comment',
'subject' => [
'ko' => '[{board_name}] 댓글에 답글이 등록되었습니다',
'en' => '[{board_name}] Reply to your comment',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
. '<p><strong>{board_name}</strong> 게시판에서 <strong>{comment_author}</strong>님이 댓글에 답글을 남겼습니다.</p>'
. '<blockquote style="border-left: 3px solid #cbd5e0; padding-left: 12px; color: #718096;">{comment_content}</blockquote>'
. $this->button('게시글 보기', '{post_url}')
. '<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
. '<p><strong>{comment_author}</strong> replied to your comment in <strong>{board_name}</strong>.</p>'
. '<blockquote style="border-left: 3px solid #cbd5e0; padding-left: 12px; color: #718096;">{comment_content}</blockquote>'
. $this->button('View Post', '{post_url}')
. '<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'board_name', 'description' => '게시판 이름'],
['key' => 'post_title', 'description' => '게시글 제목'],
['key' => 'comment_author', 'description' => '답글 작성자'],
['key' => 'comment_content', 'description' => '답글 내용 (200자)'],
['key' => 'post_url', 'description' => '게시글 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
];
}
/**
* 답변글 알림 템플릿을 반환합니다.
*
* @return array
*/
private function postReplyTemplate(): array
{
return [
'type' => 'post_reply',
'subject' => [
'ko' => '[{board_name}] 게시글에 답변글이 등록되었습니다',
'en' => '[{board_name}] Reply to your post',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
. '<p><strong>{board_name}</strong> 게시판의 게시글 "<strong>{post_title}</strong>"에 답변글이 등록되었습니다.</p>'
. $this->button('게시글 보기', '{post_url}')
. '<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
. '<p>A reply has been posted to your post "<strong>{post_title}</strong>" in <strong>{board_name}</strong>.</p>'
. $this->button('View Post', '{post_url}')
. '<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'board_name', 'description' => '게시판 이름'],
['key' => 'post_title', 'description' => '게시글 제목'],
['key' => 'post_url', 'description' => '게시글 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
];
}
/**
* 관리자 처리 알림 템플릿을 반환합니다.
*
* @return array
*/
private function postActionTemplate(): array
{
return [
'type' => 'post_action',
'subject' => [
'ko' => '[{board_name}] 게시글이 {action_type} 처리되었습니다',
'en' => '[{board_name}] Your post has been {action_type}',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
. '<p><strong>{board_name}</strong> 게시판의 게시글 "<strong>{post_title}</strong>"이(가) 관리자에 의해 <strong>{action_type}</strong> 처리되었습니다.</p>'
. '<p>문의사항이 있으시면 관리자에게 연락해 주세요.</p>'
. $this->button('게시글 보기', '{post_url}')
. '<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
. '<p>Your post "<strong>{post_title}</strong>" in <strong>{board_name}</strong> has been <strong>{action_type}</strong> by an administrator.</p>'
. '<p>If you have any questions, please contact the administrator.</p>'
. $this->button('View Post', '{post_url}')
. '<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'board_name', 'description' => '게시판 이름'],
['key' => 'post_title', 'description' => '게시글 제목'],
['key' => 'action_type', 'description' => '처리 유형 (블라인드/삭제/복원)'],
['key' => 'post_url', 'description' => '게시글 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
];
}
/**
* 관리자 새 게시글 알림 템플릿을 반환합니다.
*
* @return array
*/
private function newPostAdminTemplate(): array
{
return [
'type' => 'new_post_admin',
'subject' => [
'ko' => '[{board_name}] 새 게시글이 등록되었습니다',
'en' => '[{board_name}] New post has been created',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
. '<p><strong>{board_name}</strong> 게시판에 <strong>{post_author}</strong>님이 새 게시글을 등록했습니다.</p>'
. '<p>게시글 제목: <strong>{post_title}</strong></p>'
. $this->button('게시글 보기', '{post_url}')
. '<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
. '<p><strong>{post_author}</strong> created a new post in <strong>{board_name}</strong>.</p>'
. '<p>Title: <strong>{post_title}</strong></p>'
. $this->button('View Post', '{post_url}')
. '<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름 (관리자)'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'board_name', 'description' => '게시판 이름'],
['key' => 'post_title', 'description' => '게시글 제목'],
['key' => 'post_author', 'description' => '게시글 작성자'],
['key' => 'post_url', 'description' => '게시글 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
];
}
/**
* 신고 처리 알림 템플릿을 반환합니다.
*
* @return array
*/
private function reportActionTemplate(): array
{
return [
'type' => 'report_action',
'subject' => [
'ko' => '[{board_name}] 회원님의 {target_type} "{post_title}"이 {action_type} 처리되었습니다',
'en' => '[{board_name}] Your {target_type} "{post_title}" has been {action_type}',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
. '<p><strong>{board_name}</strong> 게시판에서 회원님의 <strong>{target_type}</strong> "<strong>{post_title}</strong>"이(가) 신고로 인해 <strong>{action_type}</strong> 처리되었습니다.</p>'
. '<p>문의사항이 있으시면 관리자에게 연락해 주세요.</p>'
. $this->button('게시글 보기', '{post_url}')
. '<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
. '<p>Your <strong>{target_type}</strong> "<strong>{post_title}</strong>" in <strong>{board_name}</strong> has been <strong>{action_type}</strong> due to reports.</p>'
. '<p>If you have any questions, please contact the administrator.</p>'
. $this->button('View Post', '{post_url}')
. '<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'board_name', 'description' => '게시판 이름'],
['key' => 'post_title', 'description' => '게시글 제목'],
['key' => 'action_type', 'description' => '처리 유형 (블라인드/삭제/복원)'],
['key' => 'target_type', 'description' => '처리 대상 (게시글/댓글)'],
['key' => 'post_url', 'description' => '게시글 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
];
}
/**
* 신고 접수 관리자 알림 템플릿을 반환합니다.
*
* @return array
*/
private function reportReceivedAdminTemplate(): array
{
return [
'type' => 'report_received_admin',
'subject' => [
'ko' => '[{board_name}] "{post_title}"에 대한 신고가 접수되었습니다',
'en' => '[{board_name}] A new report has been received for "{post_title}"',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
. '<p><strong>{board_name}</strong> 게시판에서 <strong>{target_type}</strong> "<strong>{post_title}</strong>"에 대한 새 신고가 접수되었습니다.</p>'
. '<p>신고 사유: <strong>{reason_type}</strong></p>'
. $this->button('신고 관리 페이지로 이동', '{report_url}')
. '<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
. '<p>A new report has been received for the <strong>{target_type}</strong> "<strong>{post_title}</strong>" in <strong>{board_name}</strong>.</p>'
. '<p>Reason: <strong>{reason_type}</strong></p>'
. $this->button('Go to Report Management', '{report_url}')
. '<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'board_name', 'description' => '게시판 이름'],
['key' => 'post_title', 'description' => '신고 대상 게시글 제목'],
['key' => 'target_type', 'description' => '신고 대상 유형 (게시글/댓글)'],
['key' => 'reason_type', 'description' => '신고 사유'],
['key' => 'report_url', 'description' => '신고 관리 페이지 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
];
}
/**
* 이메일 호환 CTA 버튼 HTML을 생성합니다.
*
* @param string $text 버튼 텍스트
* @param string $url 버튼 링크 URL
* @return string 인라인 스타일 버튼 HTML
*/
private function button(string $text, string $url): string
{
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;">'
. '<tr><td align="center">'
. '<a href="' . $url . '" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">'
. $text
. '</a>'
. '</td></tr></table>';
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Sirsoft\Board\Models\BoardType;
/**
* 게시판 유형 초기 시더
*
* 기본 3개 유형(basic, gallery, card)을 생성합니다.
* module.php getSeeders()에서 직접 등록됩니다.
*/
class BoardTypeSeeder extends Seeder
{
/**
* 초기 게시판 유형 데이터
*/
private const DEFAULT_BOARD_TYPES = [
[
'slug' => 'basic',
'name' => ['ko' => '기본형', 'en' => 'Basic List'],
],
[
'slug' => 'gallery',
'name' => ['ko' => '갤러리형', 'en' => 'Gallery'],
],
[
'slug' => 'card',
'name' => ['ko' => '카드형', 'en' => 'Card'],
],
];
/**
* 시더 실행
*
* @return void
*/
public function run(): void
{
foreach (self::DEFAULT_BOARD_TYPES as $boardTypeData) {
if (BoardType::where('slug', $boardTypeData['slug'])->exists()) {
$this->command->warn(" 게시판 유형 '{$boardTypeData['slug']}' 이미 존재합니다. 건너뜁니다.");
continue;
}
BoardType::create($boardTypeData);
$this->command->info(" 게시판 유형 '{$boardTypeData['slug']}' 생성 완료.");
}
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders;
use App\Traits\HasSampleSeeders;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Seeder;
/**
* 게시판 모듈 메인 시더
*
* 설치 필수 시더는 항상 실행되며, 샘플 시더는 --sample 옵션 시에만 실행됩니다.
*
* 설치 시더:
* - InstallSeeder (BoardMailTemplateSeeder 포함)
* - BoardTypeSeeder
*
* 샘플 시더 (--sample 옵션 시):
* 1. BoardSampleSeeder - 게시판 8개 (테이블 + 권한 자동 생성)
* 2. PostSampleSeeder - 게시글/댓글 샘플 (총 160건)
* 3. ReportSampleSeeder - 신고 샘플 15~20건
* 4. BoardMailSendLogSeeder - 메일 발송 이력 샘플
*
* 실행 방법:
* php artisan module:seed sirsoft-board # 설치 시더만
* php artisan module:seed sirsoft-board --sample # 설치 + 샘플
*/
class DatabaseSeeder extends Seeder
{
use HasSampleSeeders;
use HasSeederCounts;
/**
* 시더 실행
*/
public function run(): void
{
$this->command->info('');
$this->command->info('========================================');
$this->command->info(' 게시판 모듈 시더 시작');
$this->command->info('========================================');
$this->command->info('');
// 설치 필수 시더 (항상 실행)
$this->command->info('[설치] 게시판 타입 및 메일 템플릿 생성');
$this->call([
InstallSeeder::class,
BoardTypeSeeder::class,
]);
$this->command->info('');
// 샘플 시더 (--sample 옵션 시에만 실행)
if ($this->shouldIncludeSample()) {
$this->command->info('--- 게시판 샘플 시더 실행 ---');
$this->command->info('');
// 1. 게시판 샘플 생성 (테이블 + 권한 자동, count 불필요)
$this->command->info('[1/5] 게시판 샘플 생성');
$this->call(Sample\BoardSampleSeeder::class);
$this->command->info('');
// 2. 게시글/댓글 샘플 생성 (count-aware)
$this->command->info('[2/5] 게시글/댓글 샘플 생성');
$this->callWithCounts(Sample\PostSampleSeeder::class);
$this->command->info('');
// 3. 신고 샘플 생성 (count 불필요)
$this->command->info('[3/5] 신고 샘플 생성');
$this->call(Sample\ReportSampleSeeder::class);
$this->command->info('');
// 4. 메일 발송 이력 샘플
$this->command->info('[4/5] 메일 발송 이력 샘플 생성');
$this->call(Sample\BoardMailSendLogSeeder::class);
$this->command->info('');
// 5. 활동 로그 샘플 (모든 샘플 데이터 생성 후 마지막에 실행)
$this->command->info('[5/5] 활동 로그 샘플 생성');
$this->call(ActivityLogSampleSeeder::class);
$this->command->info('');
}
$this->command->info('========================================');
$this->command->info(' 게시판 모듈 시더 완료!');
$this->command->info('========================================');
$this->command->info('');
}
/**
* 카운트 옵션을 전파하며 단일 시더를 실행합니다.
*
* @param class-string $class 시더 클래스
*/
private function callWithCounts(string $class): void
{
$seeder = $this->resolve($class);
if (method_exists($seeder, 'setSeederCounts')) {
$seeder->setSeederCounts($this->seederCounts);
}
$seeder->__invoke();
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders;
use Illuminate\Database\Seeder;
/**
* 게시판 모듈 설치 시더
*
* 모듈 설치 시 실행되는 시더입니다.
* 테스트용 더미 데이터는 포함하지 않습니다.
*
* 테스트 데이터가 필요한 경우 DatabaseSeeder를 수동으로 실행하세요:
* php artisan module:seed sirsoft-board
*/
class InstallSeeder extends Seeder
{
/**
* 시더 실행
*/
public function run(): void
{
$this->call(BoardMailTemplateSeeder::class);
}
}
@@ -0,0 +1,141 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders\Sample;
use App\Enums\ExtensionOwnerType;
use App\Enums\MailSendStatus;
use App\Models\MailSendLog;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
/**
* 게시판 모듈 메일 발송 이력 개발용 시더
*
* 게시판 메일 템플릿(new_comment, reply_comment, post_reply, post_action, new_post_admin)을
* 기반으로 발송 이력 샘플 데이터를 생성합니다.
*
* 개발/테스트 용도이므로 설치 시 자동 실행되지 않습니다.
* 수동 실행: php artisan module:seed sirsoft-board --class=BoardMailSendLogSeeder
*/
class BoardMailSendLogSeeder extends Seeder
{
use HasSeederCounts;
/**
* 게시판 메일 템플릿 유형별 제목 패턴
*
* @var array<string, string>
*/
private array $templateSubjects = [
'new_comment' => '[그누보드7] 게시글에 새 댓글이 등록되었습니다',
'reply_comment' => '[그누보드7] 댓글에 답글이 등록되었습니다',
'post_reply' => '[그누보드7] 게시글에 답변글이 등록되었습니다',
'post_action' => '[그누보드7] 게시글 관리자 처리 안내',
'new_post_admin' => '[그누보드7] 새 게시글이 등록되었습니다',
];
/**
* 시더 실행
*
* @return void
*/
public function run(): void
{
$count = $this->getSeederCount('mail_send_logs', 25);
$this->command->info('게시판 메일 발송 이력 시딩 시작...');
$templateTypes = array_keys($this->templateSubjects);
$recipients = $this->getRecipients();
for ($i = 0; $i < $count; $i++) {
$templateType = $templateTypes[array_rand($templateTypes)];
$recipient = $recipients[array_rand($recipients)];
$status = $this->randomStatus();
$sentAt = $this->randomSentAt();
MailSendLog::create([
'recipient_email' => $recipient['email'],
'recipient_name' => $recipient['name'],
'subject' => $this->templateSubjects[$templateType],
'template_type' => $templateType,
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
'source' => 'notification',
'status' => $status->value,
'error_message' => $status === MailSendStatus::Failed
? $this->randomErrorMessage()
: null,
'sent_at' => $sentAt,
]);
}
$this->command->info("게시판 메일 발송 이력 시딩 완료 ({$count}건)");
}
/**
* 샘플 수신자 목록을 반환합니다.
*
* @return array<int, array{email: string, name: string}>
*/
private function getRecipients(): array
{
return [
['email' => 'hong@example.com', 'name' => '홍길동'],
['email' => 'kim@example.com', 'name' => '김철수'],
['email' => 'lee@example.com', 'name' => '이영희'],
['email' => 'park@example.com', 'name' => '박민수'],
['email' => 'choi@example.com', 'name' => '최지은'],
['email' => 'admin@example.com', 'name' => '관리자'],
['email' => 'john@example.com', 'name' => 'John Doe'],
['email' => 'jane@example.com', 'name' => 'Jane Smith'],
];
}
/**
* 가중치 기반 랜덤 상태를 반환합니다.
*
* @return MailSendStatus
*/
private function randomStatus(): MailSendStatus
{
$rand = mt_rand(1, 100);
if ($rand <= 80) {
return MailSendStatus::Sent;
} elseif ($rand <= 95) {
return MailSendStatus::Failed;
}
return MailSendStatus::Skipped;
}
/**
* 최근 90일 내 랜덤 발송 시각을 반환합니다.
*
* @return Carbon
*/
private function randomSentAt(): Carbon
{
return Carbon::now()->subDays(mt_rand(0, 90))->subHours(mt_rand(0, 23))->subMinutes(mt_rand(0, 59));
}
/**
* 랜덤 에러 메시지를 반환합니다.
*
* @return string
*/
private function randomErrorMessage(): string
{
$messages = [
'Connection timed out after 30 seconds',
'SMTP server returned 550: Mailbox not found',
'DNS resolution failed for mail server',
'Authentication failed: Invalid credentials',
'Message rejected: Recipient address rejected',
];
return $messages[array_rand($messages)];
}
}
@@ -0,0 +1,386 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders\Sample;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Services\BoardService;
/**
* 게시판 샘플 시더
*
* 다양한 설정의 게시판을 생성합니다.
* BoardService를 사용하여 테이블과 권한이 자동으로 생성됩니다.
*/
class BoardSampleSeeder extends Seeder
{
/**
* 게시판 샘플 데이터
*
* 테스트 시나리오 (8개 게시판):
* 1. notice - 공지사항 : 관리자만 글쓰기, 댓글/파일 없음, mail 알림
* 2. free - 자유게시판 : 비회원 개방, 비밀글 선택, 입력 제한 커스텀
* 3. gallery - 갤러리 : gallery 타입, 이미지만, 최신댓글 상단(DESC), 조회수 정렬
* 4. event - 이벤트 : card 타입, 댓글/파일 없음, 오래된 순 정렬(ASC)
* 5. qna - Q&A : 카테고리, 답글 depth, 비밀글 선택, 회원 비밀글 읽기 허용
* 6. members - 회원전용 : 비회원 차단, secret_mode:always + 댓글 허용
* 7. inquiry - 1:1 문의 : 비밀글 필수, 답글 depth:1, 비회원 글쓰기, mail 알림
* 8. archive - 아카이브 : 비활성(is_active:false), blocked_keywords:null(defaults 45개 적용), ASC 정렬
*
* 커버하는 설정 케이스:
* - type : basic ✓ / gallery ✓ / card ✓
* - secret_mode : disabled ✓ / enabled ✓ / always ✓
* - use_comment : true ✓ / false ✓
* - use_reply : true(depth 1/3/5) ✓ / false ✓
* - use_report : true ✓ / false ✓
* - use_file_upload : true ✓ / false ✓
* - is_active : true ✓ / false ✓
* - order_by : created_at ✓ / view_count ✓
* - order_direction : ASC ✓ / DESC ✓
* - comment_order : ASC ✓ / DESC ✓
* - notify channels : mail ✓ / database ✓
* - blocked_keywords: null(defaults 적용) ✓ / 커스텀 ✓
* - 입력 제한 : 기본값 ✓ / 커스텀 ✓
* - secret+댓글 조합: always+댓글허용 ✓
* - 권한 : posts.read-secret에 user 추가 ✓
*
* 권한 설정 원칙:
* - 기본값: g7_module_settings('sirsoft-board', 'basic_defaults.default_board_permissions') 사용
* - permissions 배열: 기본값에서 변경이 필요한 권한만 오버라이드
* - admin은 모든 권한에 포함 (기본값에서 보장)
*/
private const SAMPLE_BOARDS = [
// =========================================================
// 1. 공지사항
// 검증: 관리자 전용 글쓰기 / 댓글·답글·파일 없음 / mail 알림 채널
// notify_*_channels: defaults는 ['mail']이지만 시더에서 명시적으로 검증
// =========================================================
[
'name' => ['ko' => '공지사항', 'en' => 'Notice'],
'slug' => 'notice',
'description' => ['ko' => '중요한 공지사항을 확인하세요', 'en' => 'Check important announcements'],
'type' => 'basic',
'is_active' => true,
'secret_mode' => 'disabled',
'use_comment' => false,
'use_reply' => false,
'use_report' => false,
'use_file_upload' => false,
'show_view_count' => true,
'per_page' => 15,
'new_display_hours' => 72,
// 알림: mail 채널 — defaults.json 기본값과 동일하나 명시적으로 검증
'notify_author' => false,
'notify_admin_on_post' => true,
'notify_admin_on_post_channels' => ['mail'],
// 기본값: posts_write = ['admin', 'user'] → user 제거 (관리자만 글쓰기)
'permissions' => [
'posts_write' => ['roles' => ['admin']],
],
],
// =========================================================
// 2. 자유게시판
// 검증: 비회원 전체 개방 / 비밀글 선택 / 입력 제한 커스텀값 / 커스텀 금지어
// 입력 제한: 제목(5~100자), 내용(0~2000자), 댓글(0~500자)
// min_content_length: 0 → 내용 없이도 등록 가능
// min_comment_length: 0 → 짧은 댓글 허용 (예: "👍", "+1")
// =========================================================
[
'name' => ['ko' => '자유게시판', 'en' => 'Free Board'],
'slug' => 'free',
'description' => ['ko' => '자유롭게 이야기를 나누세요', 'en' => 'Share your thoughts freely'],
'type' => 'basic',
'is_active' => true,
'secret_mode' => 'enabled',
'use_comment' => true,
'use_reply' => false,
'use_report' => true,
'use_file_upload' => true,
'max_file_size' => 5242880, // 5MB
'max_file_count' => 5,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'],
'show_view_count' => true,
'per_page' => 20,
'new_display_hours' => 24,
// 입력 제한 커스텀 — 기본값과 다른 값으로 StorePostRequest 검증 동작 확인
'min_title_length' => 5,
'max_title_length' => 100,
'min_content_length' => 0, // 내용 제한 없음 (링크/이미지만 올리는 경우)
'max_content_length' => 2000,
'min_comment_length' => 0, // 짧은 반응 허용
'max_comment_length' => 500,
// 알림: database 채널 (defaults의 mail과 다른 케이스)
'notify_author' => true,
'notify_author_channels' => ['database'],
'notify_admin_on_post' => false,
// 금지어: 커스텀 — 기본 45개 대신 광고/스팸 특화
'blocked_keywords' => ['광고', '홍보', 'spam', '도박', '대출'],
// 기본값: posts_write = ['admin', 'user'] → guest 추가 (비회원도 글쓰기/댓글)
'permissions' => [
'posts_write' => ['roles' => ['admin', 'user', 'guest']],
'comments_write' => ['roles' => ['admin', 'user', 'guest']],
],
],
// =========================================================
// 3. 갤러리
// 검증: gallery 타입 / 이미지 파일만 / 조회수 정렬(view_count) / 최신 댓글 상단(comment_order:DESC)
// =========================================================
[
'name' => ['ko' => '갤러리', 'en' => 'Gallery'],
'slug' => 'gallery',
'description' => ['ko' => '사진과 이미지를 공유하세요', 'en' => 'Share your photos and images'],
'type' => 'gallery',
'is_active' => true,
'secret_mode' => 'disabled',
'use_comment' => true,
'use_reply' => false,
'use_report' => true,
'use_file_upload' => true,
'max_file_size' => 10485760, // 10MB
'max_file_count' => 10,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'show_view_count' => true,
'per_page' => 12,
'new_display_hours' => 24,
// 조회수 기준 내림차순 정렬 — 인기글이 상단에 노출
'order_by' => 'view_count',
'order_direction' => 'DESC',
// 최신 댓글 상단 — 뉴스피드형 게시판에서 사용
'comment_order' => 'DESC',
'max_comment_depth' => 3,
'notify_author' => true,
'notify_author_channels' => ['database'],
'notify_admin_on_post' => false,
// 기본값 그대로 — 회원만 글쓰기/댓글, 전체 읽기/다운로드
],
// =========================================================
// 4. 이벤트
// 검증: card 타입 / 댓글·답글·파일 없음 / 오래된 순 정렬(order_direction:ASC)
// order_direction:ASC — 이벤트 번호순, FAQ 순서 고정형 게시판에서 사용
// =========================================================
[
'name' => ['ko' => '이벤트', 'en' => 'Event'],
'slug' => 'event',
'description' => ['ko' => '진행 중인 이벤트를 확인하세요', 'en' => 'Check ongoing events'],
'type' => 'card',
'is_active' => true,
'secret_mode' => 'disabled',
'use_comment' => false,
'use_reply' => false,
'use_report' => false,
'use_file_upload' => true,
'max_file_size' => 10485760, // 10MB
'max_file_count' => 5,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'show_view_count' => true,
'per_page' => 12,
'new_display_hours' => 72,
// 등록순 오름차순 — 이벤트 번호순 노출
'order_by' => 'created_at',
'order_direction' => 'ASC',
'notify_author' => false,
'notify_admin_on_post' => false,
// 기본값: posts_write = ['admin', 'user'] → user 제거 (관리자만 등록)
'permissions' => [
'posts_write' => ['roles' => ['admin']],
],
],
// =========================================================
// 5. Q&A
// 검증: 카테고리 4개 / 답글 다단계(depth 3) / 비밀글 선택
// posts.read-secret에 user 추가 — 비밀글을 작성자 외 회원도 열람 가능
// =========================================================
[
'name' => ['ko' => 'Q&A', 'en' => 'Q&A'],
'slug' => 'qna',
'description' => ['ko' => '궁금한 점을 질문하고 답변을 받으세요', 'en' => 'Ask questions and get answers'],
'type' => 'basic',
'is_active' => true,
'secret_mode' => 'enabled',
'use_comment' => true,
'use_reply' => true,
'max_reply_depth' => 3,
'max_comment_depth' => 5,
'use_report' => true,
'use_file_upload' => true,
'max_file_size' => 5242880, // 5MB
'max_file_count' => 3,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'pdf', 'zip', 'txt'],
'show_view_count' => true,
'per_page' => 20,
'new_display_hours' => 24,
'categories' => ['일반문의', '기술문의', '결제문의', '기타'],
'notify_author' => true,
'notify_author_channels' => ['database'],
'notify_admin_on_post' => true,
'notify_admin_on_post_channels' => ['database'],
'blocked_keywords' => ['욕설테스트', 'badword'],
// 기본값: posts.read-secret = ['admin'] → user 추가
// 비밀글을 작성자 외 로그인 회원도 읽을 수 있는 케이스 (고객센터 담당자 등)
'permissions' => [
'posts_read-secret' => ['roles' => ['admin', 'user']],
],
],
// =========================================================
// 6. 회원전용
// 검증: 비회원 읽기/쓰기 차단 / secret_mode:always + use_comment:true 조합
// secret_mode:always이면서 댓글까지 허용 — 비밀게시판 내 댓글 동작 확인
// =========================================================
[
'name' => ['ko' => '회원전용', 'en' => 'Members Only'],
'slug' => 'members',
'description' => ['ko' => '회원만 이용 가능한 게시판입니다', 'en' => 'Members only board'],
'type' => 'basic',
'is_active' => true,
'secret_mode' => 'always',
'use_comment' => true,
'use_reply' => true,
'max_reply_depth' => 5,
'max_comment_depth' => 10,
'use_report' => true,
'use_file_upload' => true,
'max_file_size' => 5242880, // 5MB
'max_file_count' => 5,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'],
'show_view_count' => true,
'per_page' => 20,
'new_display_hours' => 24,
'notify_author' => true,
'notify_author_channels' => ['database'],
'notify_admin_on_post' => false,
// 금지어: 기본 45개 대신 스팸 특화 커스텀
'blocked_keywords' => ['스팸', 'advertisement'],
// 기본값: posts.read = ['admin', 'user', 'guest'] → guest 제거 (비회원 읽기 차단)
'permissions' => [
'posts_read' => ['roles' => ['admin', 'user']],
'comments_read' => ['roles' => ['admin', 'user']],
'attachments_download' => ['roles' => ['admin', 'user']],
],
],
// =========================================================
// 7. 1:1 문의
// 검증: 비밀글 필수(always) / 댓글 없음 / 답글 depth:1 / 비회원 글쓰기 / mail 알림
// =========================================================
[
'name' => ['ko' => '1:1 문의', 'en' => 'Contact Us'],
'slug' => 'inquiry',
'description' => ['ko' => '1:1로 문의하실 수 있습니다', 'en' => 'Contact us directly'],
'type' => 'basic',
'is_active' => true,
'secret_mode' => 'always',
'use_comment' => false,
'use_reply' => true,
'max_reply_depth' => 1,
'max_comment_depth' => 1,
'use_report' => false,
'use_file_upload' => true,
'max_file_size' => 5242880, // 5MB
'max_file_count' => 3,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'],
'show_view_count' => false,
'per_page' => 20,
'new_display_hours' => 24,
// 알림: mail 채널 — 문의 접수 즉시 이메일로 확인
'notify_author' => true,
'notify_author_channels' => ['mail'],
'notify_admin_on_post' => true,
'notify_admin_on_post_channels' => ['mail'],
// 기본값: posts_write = ['admin', 'user'] → guest 추가 (비회원도 문의 가능)
'permissions' => [
'posts_write' => ['roles' => ['admin', 'user', 'guest']],
'attachments_upload' => ['roles' => ['admin', 'user', 'guest']],
],
],
// =========================================================
// 8. 아카이브
// 검증: is_active:false (비활성) / blocked_keywords:null (defaults.json 45개 적용)
// order_direction:ASC (오래된 순)
// is_active:false — 프론트에서 접근 시 403 동작 확인
// blocked_keywords:null — 금지어를 명시하지 않았을 때 defaults.json 기본 45개가 적용되는지 확인
// =========================================================
[
'name' => ['ko' => '아카이브', 'en' => 'Archive'],
'slug' => 'archive',
'description' => ['ko' => '과거 게시글 아카이브입니다 (현재 비활성)', 'en' => 'Archive of past posts (currently inactive)'],
'type' => 'basic',
'is_active' => false, // 비활성 게시판
'secret_mode' => 'disabled',
'use_comment' => true,
'use_reply' => false,
'use_report' => false,
'use_file_upload' => false,
'show_view_count' => true,
'per_page' => 30,
'new_display_hours' => 0,
// 오래된 순 정렬 — 아카이브는 시간순 탐색
'order_by' => 'created_at',
'order_direction' => 'ASC',
'notify_author' => false,
'notify_admin_on_post' => false,
// blocked_keywords: null → defaults.json의 기본 45개 금지어 적용 확인
'blocked_keywords' => null,
// 기본값 그대로 사용
],
];
/**
* 시더 실행
*/
public function run(): void
{
$this->command->info('게시판 샘플 데이터 생성 중...');
// 관리자 사용자로 인증 (생성자 기록용)
$admin = User::whereHas('roles', function ($query) {
$query->where('identifier', 'admin');
})->first();
if ($admin) {
Auth::login($admin);
}
/** @var BoardService $boardService */
$boardService = app(BoardService::class);
// admin 사용자 UUID 목록 조회 → board_manager_ids 동적 주입
$adminIds = User::whereHas('roles', function ($query) {
$query->where('identifier', 'admin');
})->pluck('uuid')->toArray();
if (empty($adminIds)) {
$this->command->warn(' ⚠ admin 역할 사용자가 없습니다. board_manager_ids가 비어있게 됩니다.');
}
foreach (self::SAMPLE_BOARDS as $boardData) {
$boardData['board_manager_ids'] = $adminIds;
// 이미 존재하는 게시판은 스킵
if (Board::where('slug', $boardData['slug'])->exists()) {
$this->command->warn(" - {$boardData['slug']} 게시판이 이미 존재합니다. 스킵합니다.");
continue;
}
try {
$board = $boardService->createBoard($boardData);
$this->command->info(" - {$board->slug} 게시판 생성 완료 (테이블 + 권한 포함)");
} catch (\Exception $e) {
$this->command->error(" - {$boardData['slug']} 게시판 생성 실패: ".$e->getMessage());
}
}
if ($admin) {
Auth::logout();
}
$this->command->info('게시판 샘플 데이터 생성 완료: '.count(self::SAMPLE_BOARDS).'개');
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,835 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders\Sample;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\ReportReasonType;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Enums\ReportType;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Models\ReportLog;
/**
* 신고 샘플 시더
*
* 1케이스 구조: boards_reports(케이스 1행) + boards_report_logs(신고자 기록 N행)
* 다양한 상태와 사유의 신고 케이스 데이터를 생성합니다.
*/
class ReportSampleSeeder extends Seeder
{
/**
* 신고 상세 사유 템플릿
*/
private const REASON_DETAILS = [
ReportReasonType::Abuse->value => '욕설과 비방이 포함된 게시글입니다. 다른 사용자를 모욕하는 내용이 있습니다.',
ReportReasonType::HateSpeech->value => '특정 집단에 대한 혐오 발언이 포함되어 있습니다.',
ReportReasonType::Spam->value => '광고성 스팸 게시글입니다. 동일한 내용이 반복적으로 게시되고 있습니다.',
ReportReasonType::Copyright->value => '저작권이 있는 이미지/콘텐츠를 무단으로 사용하고 있습니다.',
ReportReasonType::Privacy->value => '개인정보(전화번호, 주소 등)가 동의 없이 노출되어 있습니다.',
ReportReasonType::Misinformation->value => '사실과 다른 허위 정보를 유포하고 있습니다.',
ReportReasonType::Sexual->value => '성적인 콘텐츠가 포함되어 있습니다.',
ReportReasonType::Violence->value => '폭력적인 내용이 포함된 게시글입니다.',
ReportReasonType::Other->value => '기타 사유로 신고합니다. 커뮤니티 가이드라인 위반으로 보입니다.',
];
/**
* 처리 메모 템플릿
*/
private const PROCESS_NOTES = [
ReportStatus::Review->value => '신고 내용을 검토 중입니다.',
ReportStatus::Rejected->value => '신고 내용을 검토한 결과, 커뮤니티 가이드라인 위반에 해당하지 않습니다.',
ReportStatus::Suspended->value => '신고 내용 확인 결과 규정 위반으로 판단되어 해당 콘텐츠를 숨김 처리하였습니다.',
];
/**
* 시더 실행
*/
public function run(): void
{
$this->command->info('신고 샘플 데이터 생성 중...');
// 기존 데이터 삭제 (개발 단계 — truncate 금지, delete 사용)
ReportLog::query()->delete();
Report::withTrashed()->forceDelete();
// 신고 기능이 활성화된 게시판 조회
$boards = Board::where('use_report', true)->get();
if ($boards->isEmpty()) {
$this->command->warn('신고 기능이 활성화된 게시판이 없습니다.');
return;
}
// 테스트 사용자 조회
$users = User::whereIn('email', [
'gildong@example.com',
'chulsoo@example.com',
'younghee@example.com',
])->get();
if ($users->count() < 3) {
$users = User::limit(5)->get();
}
if ($users->isEmpty()) {
$this->command->error('사용자가 없습니다. UserSampleSeeder를 먼저 실행하세요.');
return;
}
// 관리자 조회 (처리자용)
$admin = User::whereHas('roles', function ($query) {
$query->where('identifier', 'admin');
})->first();
$caseCount = 0;
// 각 상태별로 케이스 생성 (1케이스 = boards_reports 1행 + boards_report_logs N행)
foreach (ReportStatus::cases() as $status) {
// 각 상태별로 3~4건 생성
$countForStatus = rand(3, 4);
for ($i = 0; $i < $countForStatus; $i++) {
$board = $boards->random();
$targetType = ReportType::cases()[array_rand(ReportType::cases())];
// 게시글/댓글 ID 조회
$targetId = $this->getRandomTargetId($board, $targetType);
if (! $targetId) {
continue;
}
// 이미 케이스가 있는 대상인지 확인 (UNIQUE 제약)
$exists = Report::withTrashed()
->where('board_id', $board->id)
->where('target_type', $targetType)
->where('target_id', $targetId)
->exists();
if ($exists) {
continue;
}
// 스냅샷 데이터 생성
$snapshot = $this->getSnapshotData($board, $targetType, $targetId);
$createdAt = now()->subDays(rand(1, 30));
$lastReportedAt = $createdAt;
$reporterUser = $users->random();
// boards_reports 케이스 생성
$caseData = [
'board_id' => $board->id,
'target_type' => $targetType,
'target_id' => $targetId,
'author_id' => $snapshot['author_id'],
'status' => $status,
'last_reported_at' => $lastReportedAt,
'last_activated_at' => null,
'metadata' => [
'ip' => '127.0.0.1',
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Sample Browser',
],
'process_histories' => [
[
'type' => 'reported',
'action_label' => '신고 접수',
'processor_id' => null,
'reason' => null,
'reporter_count' => 1,
'created_at' => $createdAt->format('Y-m-d H:i:s'),
],
],
'created_at' => $createdAt,
'updated_at' => now()->subDays(rand(0, 15)),
];
// 처리된 상태인 경우 처리 정보 추가
if ($status !== ReportStatus::Pending && $admin) {
$processedAt = now()->subDays(rand(0, 7));
$caseData['processed_by'] = $admin->id;
$caseData['processed_at'] = $processedAt;
$caseData['process_histories'][] = [
'type' => $status->value,
'action_label' => $status->label(),
'processor_id' => $admin->id,
'reason' => self::PROCESS_NOTES[$status->value] ?? null,
'reporter_count' => null,
'created_at' => $processedAt->format('Y-m-d H:i:s'),
];
}
$case = Report::create($caseData);
// boards_report_logs 신고자 기록 생성 (1~3명)
$reporterCount = rand(1, min(3, $users->count()));
$logReporters = $users->shuffle()->take($reporterCount);
$reasonTypes = ReportReasonType::cases();
foreach ($logReporters as $idx => $logReporter) {
$reasonType = $reasonTypes[$idx % count($reasonTypes)];
ReportLog::create([
'report_id' => $case->id,
'reporter_id' => $logReporter->id,
'snapshot' => [
'board_name' => $board->getLocalizedName(),
'title' => $snapshot['title'],
'content' => $snapshot['content'],
'content_mode' => $snapshot['content_mode'] ?? 'text',
'author_name' => $snapshot['author_name'],
],
'reason_type' => $reasonType,
'reason_detail' => self::REASON_DETAILS[$reasonType->value],
'metadata' => [
'ip' => '127.0.0.'.($idx + 1),
'user_agent' => 'Mozilla/5.0 Sample Browser',
],
'created_at' => $createdAt->copy()->addMinutes($idx * 10),
'updated_at' => $createdAt->copy()->addMinutes($idx * 10),
]);
}
$caseCount++;
}
}
$this->command->info("신고 케이스 생성 완료: {$caseCount}건");
$this->command->info(' - 상태별 분포:');
foreach (ReportStatus::cases() as $status) {
$count = Report::where('status', $status)->count();
$this->command->info(" - {$status->value}: {$count}건");
}
// 비회원 작성 콘텐츠에 대한 신고 데이터 생성
$this->createGuestAuthorReports($boards, $users);
// 다중 신고자 테스트용 케이스 생성 (동일 대상에 여러 신고자)
$this->createMultiReporterCases($boards, $users, $admin);
// 재접수 케이스 생성 (반려/중단 후 재신고로 재활성된 케이스)
$this->createReactivatedCases($boards, $users, $admin);
// 대량 신고자 케이스 생성 (단일 게시글에 20명 이상 신고)
$this->createManyReportersCase($boards, $admin);
}
/**
* 비회원 작성 콘텐츠에 대한 신고 케이스를 생성합니다.
*
* @param \Illuminate\Database\Eloquent\Collection $boards 게시판 목록
* @param \Illuminate\Database\Eloquent\Collection $users 사용자 목록
*/
private function createGuestAuthorReports($boards, $users): void
{
$this->command->info('');
$this->command->info('비회원 작성 콘텐츠 신고 데이터 생성 중...');
$guestNames = ['익명사용자', '방문자123', 'Guest User'];
$count = 0;
foreach ($guestNames as $index => $guestName) {
$board = $boards->random();
$reporter = $users->random();
$reasonType = ReportReasonType::cases()[$index % count(ReportReasonType::cases())];
$targetType = ReportType::cases()[$index % count(ReportType::cases())];
$targetId = $this->getRandomTargetId($board, $targetType);
if (! $targetId) {
continue;
}
// UNIQUE 제약 확인
$exists = Report::withTrashed()
->where('board_id', $board->id)
->where('target_type', $targetType)
->where('target_id', $targetId)
->exists();
if ($exists) {
continue;
}
$createdAt = now()->subDays(rand(1, 10));
$case = Report::create([
'board_id' => $board->id,
'target_type' => $targetType,
'target_id' => $targetId,
'author_id' => null,
'status' => ReportStatus::Pending,
'last_reported_at' => $createdAt,
'last_activated_at' => null,
'metadata' => [
'ip' => '192.168.1.'.($index + 100),
'user_agent' => 'Mozilla/5.0 Guest-Author-Test',
],
'process_histories' => [
[
'type' => 'reported',
'action_label' => '신고 접수',
'processor_id' => null,
'reason' => null,
'reporter_count' => 1,
'created_at' => $createdAt->format('Y-m-d H:i:s'),
],
],
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
ReportLog::create([
'report_id' => $case->id,
'reporter_id' => $reporter->id,
'snapshot' => [
'board_name' => $board->getLocalizedName(),
'title' => '비회원 작성 테스트 게시글 '.($index + 1),
'content' => '비회원이 작성한 콘텐츠입니다.',
'content_mode' => 'text',
'author_name' => $guestName,
],
'reason_type' => $reasonType,
'reason_detail' => self::REASON_DETAILS[$reasonType->value],
'metadata' => [
'ip' => '192.168.1.'.($index + 100),
'user_agent' => 'Mozilla/5.0 Guest-Author-Test',
],
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
$count++;
}
$this->command->line("비회원 작성 콘텐츠 신고 케이스 생성 완료: {$count}건");
}
/**
* 다중 신고자 테스트용 케이스를 생성합니다.
* 동일 케이스에 여러 신고자가 logs를 추가한 상태를 테스트합니다.
*
* @param \Illuminate\Database\Eloquent\Collection $boards 게시판 목록
* @param \Illuminate\Database\Eloquent\Collection $users 사용자 목록
* @param User|null $admin 관리자
*/
private function createMultiReporterCases($boards, $users, $admin): void
{
$this->command->info('');
$this->command->info('다중 신고자 테스트용 케이스 생성 중...');
$caseCount = 0;
$targetBoards = $boards->take(min(2, $boards->count()));
foreach ($targetBoards as $board) {
foreach ([ReportType::Post, ReportType::Comment] as $targetType) {
$targetId = $this->getRandomTargetId($board, $targetType);
if (! $targetId) {
continue;
}
// UNIQUE 제약 확인
$exists = Report::withTrashed()
->where('board_id', $board->id)
->where('target_type', $targetType)
->where('target_id', $targetId)
->exists();
if ($exists) {
continue;
}
$snapshot = $this->getSnapshotData($board, $targetType, $targetId);
$createdAt = now()->subDays(rand(5, 14));
$lastReportedAt = $createdAt->copy()->addHours($users->count() - 1);
$reasonTypes = ReportReasonType::cases();
$case = Report::create([
'board_id' => $board->id,
'target_type' => $targetType,
'target_id' => $targetId,
'author_id' => $snapshot['author_id'],
'status' => ReportStatus::Pending,
'last_reported_at' => $lastReportedAt,
'last_activated_at' => null,
'metadata' => [
'ip' => '127.0.0.1',
'user_agent' => 'Mozilla/5.0 Multi-Reporter-Test',
],
'process_histories' => [
[
'type' => 'reported',
'action_label' => '신고 접수',
'processor_id' => null,
'reason' => null,
'reporter_count' => $users->count(),
'created_at' => $createdAt->format('Y-m-d H:i:s'),
],
],
'created_at' => $createdAt,
'updated_at' => $lastReportedAt,
]);
// 모든 사용자가 각각 신고 (boards_report_logs)
foreach ($users as $idx => $reporter) {
$reasonType = $reasonTypes[$idx % count($reasonTypes)];
$logCreatedAt = $createdAt->copy()->addHours($idx);
ReportLog::create([
'report_id' => $case->id,
'reporter_id' => $reporter->id,
'snapshot' => [
'board_name' => $board->getLocalizedName(),
'title' => $snapshot['title'],
'content' => $snapshot['content'],
'content_mode' => $snapshot['content_mode'] ?? 'text',
'author_name' => $snapshot['author_name'],
],
'reason_type' => $reasonType,
'reason_detail' => self::REASON_DETAILS[$reasonType->value],
'metadata' => [
'ip' => '127.0.0.'.($idx + 1),
'user_agent' => 'Mozilla/5.0 Multi-Reporter-Test',
],
'created_at' => $logCreatedAt,
'updated_at' => $logCreatedAt,
]);
}
$boardName = $board->getLocalizedName();
$this->command->line(" - [{$boardName}] {$targetType->value} #{$targetId}: {$users->count()}명 신고");
$caseCount++;
}
}
$this->command->line("다중 신고자 테스트 케이스 생성 완료: {$caseCount}건");
}
/**
* 재접수 케이스를 생성합니다.
*
* 시나리오:
* A. 반려 후 재신고 → 현재 pending (단순 재접수)
* B. 반려 후 재신고 → 다시 반려 (재접수 후 재처리)
* C. 게시 중단 후 재신고 → 현재 pending (중단 후 재접수)
* D. 반려 후 재신고 → 중단 처리 (재접수 후 중단)
* E. 게시판 삭제 후 남은 신고 케이스 (board_id null — 스냅샷만 남음)
*
* @param \Illuminate\Database\Eloquent\Collection $boards 게시판 목록
* @param \Illuminate\Database\Eloquent\Collection $users 사용자 목록
* @param User|null $admin 관리자
*/
private function createReactivatedCases($boards, $users, $admin): void
{
$this->command->info('');
$this->command->info('재접수 케이스 생성 중...');
$caseCount = 0;
$reasonTypes = ReportReasonType::cases();
// 시나리오별 정의 [현재 status, 처리 이력 타입 목록, last_activated_at 설정 여부]
$scenarios = [
'A' => ['status' => ReportStatus::Pending, 'histories' => ['reported', 'rejected', 're_reported'], 'label' => '반려 후 재접수 (현재: 접수)'],
'B' => ['status' => ReportStatus::Rejected, 'histories' => ['reported', 'rejected', 're_reported', 'rejected'], 'label' => '반려 후 재접수 후 재반려'],
'C' => ['status' => ReportStatus::Pending, 'histories' => ['reported', 'suspended', 're_reported'], 'label' => '중단 후 재접수 (현재: 접수)'],
'D' => ['status' => ReportStatus::Suspended, 'histories' => ['reported', 'rejected', 're_reported', 'suspended'], 'label' => '재접수 후 중단 처리'],
];
foreach ($scenarios as $key => $scenario) {
$board = $boards->random();
$targetType = ReportType::cases()[array_rand(ReportType::cases())];
$targetId = $this->getRandomTargetId($board, $targetType);
if (! $targetId) {
$this->command->line(" - 시나리오 {$key}: 대상 없음 (건너뜀)");
continue;
}
$exists = Report::withTrashed()
->where('board_id', $board->id)
->where('target_type', $targetType)
->where('target_id', $targetId)
->exists();
if ($exists) {
$this->command->line(" - 시나리오 {$key}: 이미 케이스 존재 (건너뜀)");
continue;
}
$snapshot = $this->getSnapshotData($board, $targetType, $targetId);
// 시간 흐름: 최초 신고 → 처리 → 재신고 → (재처리)
$firstReportedAt = now()->subDays(rand(20, 30));
$firstProcessedAt = $firstReportedAt->copy()->addDays(rand(1, 3));
$reReportedAt = $firstProcessedAt->copy()->addDays(rand(1, 5));
$lastProcessedAt = $reReportedAt->copy()->addDays(rand(1, 3));
// process_histories 구성
$histories = [];
$reporterCount = 0;
foreach ($scenario['histories'] as $idx => $type) {
$reporterCount++;
$isReportEvent = in_array($type, ['reported', 're_reported']);
$entryTime = match ($idx) {
0 => $firstReportedAt,
1 => $firstProcessedAt,
2 => $reReportedAt,
default => $lastProcessedAt,
};
$histories[] = [
'type' => $type,
'action_label' => $type,
'processor_id' => ($isReportEvent || ! $admin) ? null : $admin->id,
'reason' => $isReportEvent ? null : (self::PROCESS_NOTES[$type] ?? null),
'reporter_count' => $isReportEvent ? $reporterCount : null,
'created_at' => $entryTime->format('Y-m-d H:i:s'),
];
}
// 현재 status에 따른 처리자 정보
$currentStatus = $scenario['status'];
$isCurrentlyProcessed = in_array($currentStatus, [ReportStatus::Rejected, ReportStatus::Suspended]);
$caseData = [
'board_id' => $board->id,
'target_type' => $targetType,
'target_id' => $targetId,
'author_id' => $snapshot['author_id'],
'status' => $currentStatus,
'processed_by' => ($isCurrentlyProcessed && $admin) ? $admin->id : null,
'processed_at' => $isCurrentlyProcessed ? $lastProcessedAt : null,
'last_reported_at' => $reReportedAt,
'last_activated_at' => $reReportedAt,
'metadata' => ['ip' => '127.0.0.1', 'user_agent' => 'Mozilla/5.0 Reactivated-Test'],
'process_histories' => $histories,
'created_at' => $firstReportedAt,
'updated_at' => $isCurrentlyProcessed ? $lastProcessedAt : $reReportedAt,
];
$case = Report::create($caseData);
// 최초 신고자 log
$firstReporter = $users->random();
ReportLog::create([
'report_id' => $case->id,
'reporter_id' => $firstReporter->id,
'snapshot' => [
'board_name' => $board->getLocalizedName(),
'title' => $snapshot['title'],
'content' => $snapshot['content'],
'content_mode' => $snapshot['content_mode'] ?? 'text',
'author_name' => $snapshot['author_name'],
],
'reason_type' => $reasonTypes[0],
'reason_detail' => self::REASON_DETAILS[$reasonTypes[0]->value],
'metadata' => ['ip' => '127.0.0.1', 'user_agent' => 'Mozilla/5.0'],
'created_at' => $firstReportedAt,
'updated_at' => $firstReportedAt,
]);
// 재신고자 log (다른 사용자 — 없으면 건너뜀)
$filteredUsers = $users->filter(fn ($u) => $u->id !== $firstReporter->id);
if ($filteredUsers->isEmpty()) {
$caseCount++;
continue;
}
$reReporter = $filteredUsers->random();
ReportLog::create([
'report_id' => $case->id,
'reporter_id' => $reReporter->id,
'snapshot' => [
'board_name' => $board->getLocalizedName(),
'title' => $snapshot['title'],
'content' => $snapshot['content'],
'content_mode' => $snapshot['content_mode'] ?? 'text',
'author_name' => $snapshot['author_name'],
],
'reason_type' => $reasonTypes[1 % count($reasonTypes)],
'reason_detail' => self::REASON_DETAILS[$reasonTypes[1 % count($reasonTypes)]->value],
'metadata' => ['ip' => '127.0.0.2', 'user_agent' => 'Mozilla/5.0'],
'created_at' => $reReportedAt,
'updated_at' => $reReportedAt,
]);
$boardName = $board->getLocalizedName();
$this->command->line(" - 시나리오 {$key} [{$boardName}] {$targetType->value} #{$targetId}: {$scenario['label']}");
$caseCount++;
}
// 시나리오 E: board_id null (게시판 삭제 후 남은 신고 케이스)
$this->command->line(' - 시나리오 E: 게시판 삭제 후 남은 신고 케이스 생성');
$reporter = $users->random();
$orphanAt = now()->subDays(rand(10, 20));
$orphanCase = Report::create([
'board_id' => null,
'target_type' => ReportType::Post,
'target_id' => 99999,
'author_id' => null,
'status' => ReportStatus::Pending,
'last_reported_at' => $orphanAt,
'last_activated_at' => null,
'metadata' => ['ip' => '10.0.0.1', 'user_agent' => 'Mozilla/5.0 Orphan-Test'],
'process_histories' => [
[
'type' => 'reported',
'action_label' => '신고 접수',
'processor_id' => null,
'reason' => null,
'reporter_count' => 1,
'created_at' => $orphanAt->format('Y-m-d H:i:s'),
],
],
'created_at' => $orphanAt,
'updated_at' => $orphanAt,
]);
ReportLog::create([
'report_id' => $orphanCase->id,
'reporter_id' => $reporter->id,
'snapshot' => [
'board_name' => '삭제된 게시판',
'title' => '삭제된 게시판의 게시글',
'content' => '게시판이 삭제되어 원본을 확인할 수 없는 신고 케이스입니다.',
'content_mode' => 'text',
'author_name' => '알 수 없음',
],
'reason_type' => ReportReasonType::Spam,
'reason_detail' => self::REASON_DETAILS[ReportReasonType::Spam->value],
'metadata' => ['ip' => '10.0.0.1', 'user_agent' => 'Mozilla/5.0'],
'created_at' => $orphanAt,
'updated_at' => $orphanAt,
]);
$caseCount++;
$this->command->line("재접수/특수 케이스 생성 완료: {$caseCount}건");
}
/**
* 대량 신고자 케이스를 생성합니다.
* 단일 게시글(또는 댓글)에 20명 이상이 신고한 시나리오입니다.
*
* @param \Illuminate\Database\Eloquent\Collection $boards 게시판 목록
* @param User|null $admin 관리자
*/
private function createManyReportersCase($boards, $admin): void
{
$this->command->info('');
$this->command->info('대량 신고자 케이스 생성 중 (20명 이상)...');
// 사용자 최대 25명 조회 (20명 이상 확보)
$manyUsers = User::orderBy('id')->limit(25)->get();
if ($manyUsers->count() < 5) {
$this->command->warn('사용자가 5명 미만입니다. 대량 신고자 케이스 생성을 건너뜁니다.');
return;
}
$board = $boards->first();
$reasonTypes = ReportReasonType::cases();
$caseCount = 0;
foreach ([ReportType::Post, ReportType::Comment] as $targetType) {
$targetId = $this->getRandomTargetId($board, $targetType);
if (! $targetId) {
continue;
}
// UNIQUE 제약 확인
$exists = Report::withTrashed()
->where('board_id', $board->id)
->where('target_type', $targetType)
->where('target_id', $targetId)
->exists();
if ($exists) {
continue;
}
$snapshot = $this->getSnapshotData($board, $targetType, $targetId);
$createdAt = now()->subDays(rand(3, 7));
$lastReportedAt = $createdAt->copy()->addMinutes(($manyUsers->count() - 1) * 5);
$case = Report::create([
'board_id' => $board->id,
'target_type' => $targetType,
'target_id' => $targetId,
'author_id' => $snapshot['author_id'],
'status' => ReportStatus::Pending,
'last_reported_at' => $lastReportedAt,
'last_activated_at' => null,
'metadata' => [
'ip' => '127.0.0.1',
'user_agent' => 'Mozilla/5.0 Many-Reporters-Test',
],
'process_histories' => [
[
'type' => 'reported',
'action_label' => '신고 접수',
'processor_id' => null,
'reason' => null,
'reporter_count' => $manyUsers->count(),
'created_at' => $createdAt->format('Y-m-d H:i:s'),
],
],
'created_at' => $createdAt,
'updated_at' => $lastReportedAt,
]);
// 각 사용자별 신고 log 생성
foreach ($manyUsers as $idx => $reporter) {
$reasonType = $reasonTypes[$idx % count($reasonTypes)];
$logCreatedAt = $createdAt->copy()->addMinutes($idx * 5);
ReportLog::create([
'report_id' => $case->id,
'reporter_id' => $reporter->id,
'snapshot' => [
'board_name' => $board->getLocalizedName(),
'title' => $snapshot['title'],
'content' => $snapshot['content'],
'content_mode' => $snapshot['content_mode'] ?? 'text',
'author_name' => $snapshot['author_name'],
],
'reason_type' => $reasonType,
'reason_detail' => self::REASON_DETAILS[$reasonType->value],
'metadata' => [
'ip' => '10.10.'.intdiv($idx, 255).'.'.($idx % 255 + 1),
'user_agent' => 'Mozilla/5.0 Many-Reporters-Test',
],
'created_at' => $logCreatedAt,
'updated_at' => $logCreatedAt,
]);
}
$boardName = $board->getLocalizedName();
$this->command->line(" - [{$boardName}] {$targetType->value} #{$targetId}: {$manyUsers->count()}명 신고");
$caseCount++;
// 게시글 타입 1건 생성 후 종료 (댓글도 성공하면 2건)
}
$this->command->line("대량 신고자 케이스 생성 완료: {$caseCount}건 (신고자 수: {$manyUsers->count()}명)");
}
/**
* 랜덤 대상 ID를 조회합니다.
*
* @param Board $board 게시판
* @param ReportType $targetType 대상 타입
* @return int|null 대상 ID
*/
private function getRandomTargetId(Board $board, ReportType $targetType): ?int
{
if ($targetType === ReportType::Post) {
$record = DB::table('board_posts')
->where('board_id', $board->id)
->inRandomOrder()
->first();
} else {
$record = DB::table('board_comments')
->where('board_id', $board->id)
->inRandomOrder()
->first();
}
return $record?->id;
}
/**
* 스냅샷 데이터를 조회합니다.
*
* @param Board $board 게시판
* @param ReportType $targetType 대상 타입
* @param int $targetId 대상 ID
* @return array<string, mixed> 스냅샷 데이터
*/
private function getSnapshotData(Board $board, ReportType $targetType, int $targetId): array
{
if ($targetType === ReportType::Post) {
$record = DB::table('board_posts')
->where('board_id', $board->id)
->where('id', $targetId)
->first();
if ($record) {
$authorId = $record->user_id ?? null;
// 외래키 제약: 존재하지 않는 유저 ID는 null 처리
if ($authorId && ! User::find($authorId)) {
$authorId = null;
}
$authorName = $record->author_name;
if (! $authorName && $authorId) {
$user = User::find($authorId);
$authorName = $user?->name ?? '알 수 없음';
}
return [
'author_id' => $authorId,
'title' => $record->title,
'content' => strip_tags($record->content),
'content_mode' => $record->content_mode ?? 'text',
'author_name' => $authorName ?? '알 수 없음',
];
}
} else {
$comment = DB::table('board_comments')
->where('board_id', $board->id)
->where('id', $targetId)
->first();
if ($comment) {
$authorId = $comment->user_id ?? null;
// 외래키 제약: 존재하지 않는 유저 ID는 null 처리
if ($authorId && ! User::find($authorId)) {
$authorId = null;
}
$authorName = $comment->author_name;
if (! $authorName && $authorId) {
$user = User::find($authorId);
$authorName = $user?->name ?? '알 수 없음';
}
// 댓글이 속한 게시글 제목 조회
$postTitle = null;
if ($comment->post_id) {
$post = DB::table('board_posts')
->where('board_id', $board->id)
->where('id', $comment->post_id)
->first();
$postTitle = $post?->title;
}
return [
'author_id' => $authorId,
'title' => $postTitle,
'content' => $comment->content,
'content_mode' => 'text',
'author_name' => $authorName ?? '알 수 없음',
];
}
}
return [
'author_id' => null,
'title' => '삭제된 콘텐츠',
'content' => '원본 콘텐츠가 삭제되었습니다.',
'content_mode' => 'text',
'author_name' => '알 수 없음',
];
}
}
@@ -0,0 +1,21 @@
{
"identifier": "sirsoft-board",
"vendor": "sirsoft",
"name": {
"ko": "게시판",
"en": "Board"
},
"version": "1.0.0-beta.1",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
"g7_version": ">=7.0.0-beta.1",
"dependencies": {
"modules": {},
"plugins": {}
},
"github_url": "https://github.com/gnuboard/g7-module-sirsoft-board",
"github_changelog_url": "https://github.com/gnuboard/g7-module-sirsoft-board/blob/main/CHANGELOG.md"
}
+332
View File
@@ -0,0 +1,332 @@
<?php
namespace Modules\Sirsoft\Board;
use App\Extension\AbstractModule;
use App\Models\Role;
use Illuminate\Database\Seeder;
use Modules\Sirsoft\Board\Database\Seeders\BoardTypeSeeder;
use Modules\Sirsoft\Board\Database\Seeders\InstallSeeder;
use Modules\Sirsoft\Board\Listeners\ActivityLogDescriptionResolver;
use Modules\Sirsoft\Board\Listeners\BoardActivityLogListener;
use Modules\Sirsoft\Board\Listeners\BoardNotificationChannelListener;
use Modules\Sirsoft\Board\Listeners\BoardNotificationListener;
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
use Modules\Sirsoft\Board\Listeners\SearchPostsListener;
use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener;
use Modules\Sirsoft\Board\Listeners\SeoBoardSettingsCacheListener;
use Modules\Sirsoft\Board\Listeners\UserNotificationSettingsListener;
class Module extends AbstractModule
{
/**
* 모듈 제거 시 동적으로 생성된 게시판별 역할을 정리합니다.
*
* 게시판 생성 시 동적으로 생성된 sirsoft-board.{slug}.manager/step 역할은
* getRoles()에 포함되지 않으므로, ModuleManager::removeModuleRoles()에서 처리되지 않습니다.
* 따라서 uninstall()에서 직접 정리합니다.
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool
{
// 게시판별 동적 역할 정리 (sirsoft-board.*.manager, sirsoft-board.*.step)
Role::where('extension_type', 'module')
->where('extension_identifier', 'sirsoft-board')
->each(function (Role $role) {
$role->permissions()->detach();
$role->users()->detach();
$role->delete();
});
return parent::uninstall();
}
/**
* 모듈 권한 목록 반환 (계층형 구조, 다국어 지원)
*
* 구조: 모듈(1레벨) → 카테고리(2레벨) → 개별 권한(3레벨)
* identifier는 자동 생성됨: {module}.{category}.{action}
* 게시판별 권한(sirsoft-board.{slug}.*)은 게시판 생성 시 BoardPermissionService에서 동적으로 생성됨
*
* 모듈 레벨 권한만 정의:
* - sirsoft-board.boards.* (게시판 관리)
* - sirsoft-board.reports.* (신고 관리)
*/
public function getPermissions(): array
{
return [
'name' => [
'ko' => '게시판',
'en' => 'Board',
],
'description' => [
'ko' => '게시판 모듈 권한',
'en' => 'Board module permissions',
],
'categories' => [
// 게시판 관리 권한 (type: admin)
[
'identifier' => 'boards',
'resource_route_key' => 'board',
'owner_key' => 'created_by',
'name' => [
'ko' => '게시판 관리',
'en' => 'Board Management',
],
'description' => [
'ko' => '게시판 관리 권한',
'en' => 'Board management permissions',
],
'permissions' => [
[
'action' => 'read',
'name' => [
'ko' => '게시판 조회',
'en' => 'View Boards',
],
'description' => [
'ko' => '게시판 목록 및 상세 조회',
'en' => 'View board list and details',
],
'type' => 'admin',
'roles' => ['admin', 'manager'],
],
[
'action' => 'create',
'name' => [
'ko' => '게시판 생성',
'en' => 'Create Board',
],
'description' => [
'ko' => '새 게시판 생성',
'en' => 'Create new board',
],
'type' => 'admin',
'roles' => ['admin', 'manager'],
],
[
'action' => 'update',
'name' => [
'ko' => '게시판 수정',
'en' => 'Update Board',
],
'description' => [
'ko' => '게시판 설정 수정',
'en' => 'Update board settings',
],
'type' => 'admin',
'roles' => ['admin', 'manager'],
],
[
'action' => 'delete',
'name' => [
'ko' => '게시판 삭제',
'en' => 'Delete Board',
],
'description' => [
'ko' => '게시판 삭제',
'en' => 'Delete board',
],
'type' => 'admin',
'roles' => ['admin'],
],
],
],
// 환경설정 권한 (type: admin)
[
'identifier' => 'settings',
'name' => [
'ko' => '환경설정',
'en' => 'Settings',
],
'description' => [
'ko' => '게시판 환경설정 권한',
'en' => 'Board settings permissions',
],
'permissions' => [
[
'action' => 'read',
'name' => [
'ko' => '환경설정 조회',
'en' => 'View Settings',
],
'description' => [
'ko' => '게시판 환경설정 조회',
'en' => 'View board settings',
],
'type' => 'admin',
'roles' => ['admin'],
],
[
'action' => 'update',
'name' => [
'ko' => '환경설정 수정',
'en' => 'Update Settings',
],
'description' => [
'ko' => '게시판 환경설정 수정',
'en' => 'Update board settings',
],
'type' => 'admin',
'roles' => ['admin'],
],
],
],
// 신고 관리 권한 (type: admin)
[
'identifier' => 'reports',
'resource_route_key' => 'report',
'owner_key' => 'reporter_id',
'name' => [
'ko' => '게시판 신고 관리',
'en' => 'Report Management',
],
'description' => [
'ko' => '게시판 신고 관리 권한',
'en' => 'Board report management permissions',
],
'permissions' => [
[
'action' => 'view',
'name' => [
'ko' => '신고 조회',
'en' => 'View Reports',
],
'description' => [
'ko' => '신고 목록 및 상세 조회',
'en' => 'View report list and details',
],
'type' => 'admin',
'roles' => ['admin', 'manager'],
],
[
'action' => 'manage',
'name' => [
'ko' => '신고 처리',
'en' => 'Manage Reports',
],
'description' => [
'ko' => '신고 상태 변경 및 처리',
'en' => 'Update report status and process',
],
'type' => 'admin',
'roles' => ['admin'],
],
],
],
],
];
}
/**
* 모듈 설정 파일 목록 반환
*/
public function getConfig(): array
{
return [
'sirsoft-board' => $this->getModulePath().'/config/board.php',
];
}
/**
* 모듈 설치 시 실행할 시더 목록 반환
*
* BoardTypeSeeder: 기본 게시판 유형(basic, gallery, card) 생성
* InstallSeeder: 기본 메일 템플릿 5종 생성
*
* 그 외 시더(DatabaseSeeder, BoardSampleSeeder, PostSampleSeeder, ReportSampleSeeder)는
* 테스트용 더미 데이터 시더이므로 설치 시 실행하지 않습니다.
* 테스트 데이터가 필요한 경우 수동으로 실행하세요:
* php artisan module:seed sirsoft-board
*
* @return array<class-string<Seeder>> 시더 클래스명 배열 (FQCN)
*/
public function getSeeders(): array
{
return [
BoardTypeSeeder::class,
InstallSeeder::class,
];
}
/**
* 훅 리스너 목록 반환
*/
public function getHookListeners(): array
{
return [
UserNotificationSettingsListener::class,
SearchPostsListener::class,
BoardNotificationListener::class,
BoardNotificationChannelListener::class,
BoardActivityLogListener::class,
ActivityLogDescriptionResolver::class,
SeoBoardCacheListener::class,
SeoBoardSettingsCacheListener::class,
EcommerceInquiryHookListener::class,
];
}
/**
* 스토리지 디스크 설정 반환
*/
public function getStorageDisk(): string
{
return config('sirsoft-board.attachment.disk', 'modules');
}
/**
* 관리자 메뉴 정의
*/
public function getAdminMenus(): array
{
return [
[
'name' => [
'ko' => '게시판 관리',
'en' => 'Board Management',
],
'slug' => 'sirsoft-board',
'url' => null,
'icon' => 'fas fa-clipboard-list',
'order' => 30,
'children' => [
[
'name' => [
'ko' => '환경설정',
'en' => 'Settings',
],
'slug' => 'sirsoft-board-settings',
'url' => '/admin/boards/settings',
'icon' => 'fas fa-cog',
'order' => 1,
'permission' => 'sirsoft-board.settings.read',
],
[
'name' => [
'ko' => '게시판 목록',
'en' => 'Board List',
],
'slug' => 'sirsoft-board-list',
'url' => '/admin/boards',
'icon' => 'fas fa-list',
'order' => 2,
'permission' => 'sirsoft-board.boards.read',
],
[
'name' => [
'ko' => '게시판 신고현황',
'en' => 'Board Reports',
],
'slug' => 'sirsoft-board-reports',
'url' => '/admin/boards/reports',
'icon' => 'fas fa-flag',
'order' => 3,
'permission' => 'sirsoft-board.reports.view',
],
],
],
];
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,20 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.0-beta.1",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
"vite": "^5.4.14",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,293 @@
{
"data_sources": [
{
"id": "board_list_for_inquiry",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"params": {
"per_page": "200"
},
"fallback": null
}
],
"extension_point": "inquiry_board_setting",
"mode": "replace",
"components": [
{
"id": "inquiry_board_setting_active",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"id": "inquiry_board_setting_description",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.description"
},
{
"id": "inquiry_board_field",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-1.5"
},
"children": [
{
"id": "inquiry_board_label",
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.board_label"
},
{
"id": "inquiry_board_select",
"type": "basic",
"name": "Select",
"props": {
"name": "inquiry.board_slug",
"value": "{{_local.form?.inquiry?.board_slug ?? ''}}",
"options": "{{[{value: '', label: $t('sirsoft-board.admin.ecommerce_settings.inquiry.board_placeholder')}].concat((board_list_for_inquiry?.data?.data ?? []).map(b => ({value: b.slug, label: b.name})))}}",
"className": "{{_local.errors?.['inquiry.board_slug'] ? 'input input-error' : 'input'}}",
"disabled": "{{_computed.isReadOnly}}"
}
},
{
"id": "inquiry_board_select_error",
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['inquiry.board_slug']}}",
"props": {
"className": "text-red-500 text-xs mt-1 block"
},
"text": "{{_local.errors?.['inquiry.board_slug']?.[0] ?? ''}}"
}
]
},
{
"id": "inquiry_board_hints",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"id": "inquiry_board_hint_secret",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.hint_secret"
},
{
"id": "inquiry_board_hint_category",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.hint_category"
},
{
"id": "inquiry_board_actions",
"type": "basic",
"name": "Div",
"if": "{{!!(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}",
"props": {
"className": "flex flex-col md:flex-row gap-3"
},
"children": [
{
"id": "inquiry_board_go_edit_card",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col justify-between gap-3 flex-1 p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "flex items-start gap-1.5 text-xs text-gray-500 dark:text-gray-400"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "gear", "className": "w-3 h-3 shrink-0 mt-0.5" }
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.go_to_board_edit_desc"
}
]
},
{
"id": "inquiry_board_go_edit",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "self-start inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "pen-to-square", "className": "w-3 h-3" }
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.go_to_board_edit"
}
],
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/edit"
}
}
]
}
]
},
{
"id": "inquiry_board_go_posts_card",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col justify-between gap-3 flex-1 p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "flex items-start gap-1.5 text-xs text-gray-500 dark:text-gray-400"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "clipboard-list", "className": "w-3 h-3 shrink-0 mt-0.5" }
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.go_to_posts_desc"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-2"
},
"children": [
{
"id": "inquiry_board_go_posts",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "list", "className": "w-3 h-3" }
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.go_to_posts"
}
],
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{settings?.data?.inquiry?.board_slug}}"
}
}
]
},
{
"id": "inquiry_board_add_menu",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 bg-white dark:bg-gray-900 border border-blue-500 dark:border-blue-400 rounded hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "plus", "className": "w-3 h-3" }
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.add_to_admin_menu"
}
],
"actions": [
{
"type": "click",
"handler": "apiCall",
"target": "/api/modules/sirsoft-board/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/add-to-menu",
"params": { "method": "POST" },
"auth_required": true,
"onSuccess": [
{
"handler": "toast",
"params": { "type": "success", "message": "{{result.message}}" }
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "admin_menu", "sync": true }
}
],
"onError": [
{
"handler": "toast",
"params": { "type": "error", "message": "{{error.message}}" }
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
],
"priority": 100
}
@@ -0,0 +1,192 @@
{
"target_layout": "admin_user_detail",
"injections": [
{
"target_id": "user_detail_tabs",
"position": "inject_props",
"props": {
"tabs": {
"_append": [
{
"id": "ext_notification",
"label": "$t:sirsoft-board.admin.users.form.sections.notification_settings",
"iconName": "cubes"
}
]
}
}
},
{
"target_id": "extension_tab_content",
"position": "append_child",
"components": [
{
"id": "ext_notification_content",
"type": "basic",
"name": "Div",
"props": {
"id": "ext_notification",
"className": "scroll-mt-32"
},
"children": [
{
"id": "section_notification_settings",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "bell",
"className": "w-5 h-5 text-gray-600 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
}
]
},
{
"type": "composite",
"name": "ExtensionBadge",
"props": {
"type": "module",
"identifier": "sirsoft-board",
"installedModules": "{{_global.installedModules}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 gap-4"
},
"children": [
{
"id": "field_notify_post_complete",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_complete"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
},
"text": "{{user?.data?.notify_post_complete ? '$t:admin.users.detail.enabled' : '$t:admin.users.detail.disabled'}}"
}
]
},
{
"id": "field_notify_post_reply",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_reply"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
},
"text": "{{user?.data?.notify_post_reply ? '$t:admin.users.detail.enabled' : '$t:admin.users.detail.disabled'}}"
}
]
},
{
"id": "field_notify_comment",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.comment"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
},
"text": "{{user?.data?.notify_comment ? '$t:admin.users.detail.enabled' : '$t:admin.users.detail.disabled'}}"
}
]
},
{
"id": "field_notify_reply_comment",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.reply_comment"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
},
"text": "{{user?.data?.notify_reply_comment ? '$t:admin.users.detail.enabled' : '$t:admin.users.detail.disabled'}}"
}
]
}
]
}
]
}
]
}
]
}
],
"priority": 300
}
@@ -0,0 +1,210 @@
{
"target_layout": "admin_user_form",
"injections": [
{
"target_id": "extension_slot",
"position": "append_child",
"components": [
{
"id": "section_notification_settings",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "bell",
"className": "w-5 h-5 text-gray-600 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
}
]
},
{
"type": "composite",
"name": "ExtensionBadge",
"props": {
"type": "module",
"identifier": "sirsoft-board",
"installedModules": "{{_global.installedModules}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 gap-4"
},
"children": [
{
"id": "field_notify_post_complete",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_post_complete",
"checked": "{{_local.form?.notify_post_complete ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_complete"
}
]
}
]
},
{
"id": "field_notify_post_reply",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_post_reply",
"checked": "{{_local.form?.notify_post_reply ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_reply"
}
]
}
]
},
{
"id": "field_notify_comment",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_comment",
"checked": "{{_local.form?.notify_comment ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.comment"
}
]
}
]
},
{
"id": "field_notify_reply_comment",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_reply_comment",
"checked": "{{_local.form?.notify_reply_comment ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.reply_comment"
}
]
}
]
}
]
}
]
}
]
}
],
"priority": 300
}
@@ -0,0 +1,336 @@
/**
* 게시판 생성/수정 폼 레이아웃 JSON 구조 검증 테스트
*
* @description
* - 기본 탭(_tab_basic): use_comment/use_reply 토글 제거, secret_mode→show_view_count 순서
* - 게시글 탭(_tab_post): section_reply 분리, use_comment 서브필드 분리, condition→if 마이그레이션
*/
import { describe, it, expect } from 'vitest';
// 레이아웃 JSON 임포트
import tabBasic from '../../../layouts/admin/partials/admin_board_form/_tab_basic.json';
import tabPost from '../../../layouts/admin/partials/admin_board_form/_tab_post.json';
import tabPermissions from '../../../layouts/admin/partials/admin_board_form/_tab_permissions.json';
/**
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
*/
function findById(node: any, id: string): any | null {
if (!node) return null;
if (node.id === id) return node;
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
const found = findById(child, id);
if (found) return found;
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
const found = findById(child as any, id);
if (found) return found;
}
}
}
}
return null;
}
/**
* JSON 트리에서 특정 name을 가진 컴포넌트를 모두 찾습니다.
*/
function findByName(node: any, name: string): any[] {
const results: any[] = [];
if (!node) return results;
if (node.name === name) {
results.push(node);
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findByName(child, name));
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
results.push(...findByName(child as any, name));
}
}
}
}
return results;
}
/**
* JSON 트리에서 form name prop으로 Input/Select/Toggle를 찾습니다.
*/
function findFormFields(node: any): string[] {
const names: string[] = [];
if (!node) return names;
if ((node.name === 'Input' || node.name === 'Select' || node.name === 'Toggle') && node.props?.name) {
names.push(node.props.name);
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
names.push(...findFormFields(child));
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
names.push(...findFormFields(child as any));
}
}
}
}
return names;
}
/**
* JSON 트리에서 deprecated `condition` 속성을 가진 노드를 모두 찾습니다.
*/
function findConditionNodes(node: any): any[] {
const results: any[] = [];
if (!node) return results;
if (node.condition !== undefined) {
results.push(node);
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findConditionNodes(child));
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
results.push(...findConditionNodes(child as any));
}
}
}
}
return results;
}
// ============================================================
// _tab_basic.json 테스트
// ============================================================
describe('admin_board_form/_tab_basic.json', () => {
it('파일이 유효한 JSON 구조이다', () => {
expect(tabBasic).toBeDefined();
expect(tabBasic.id).toBe('tab_content_basic');
});
it('기본 섹션 카드에 use_comment, use_reply 토글이 없다 (게시글 탭으로 분리됨)', () => {
// _tab_basic.json의 section_basic_card 하위에 use_comment, use_reply 없어야 함
const fields = findFormFields(tabBasic);
expect(fields).not.toContain('use_comment');
expect(fields).not.toContain('use_reply');
});
it('기본 탭에서 secret_mode가 show_view_count보다 앞에 위치한다', () => {
// section_basic_card 하위 children에서 순서 확인
const card = findById(tabBasic, 'section_basic_card');
expect(card).toBeDefined();
const children = card.children ?? [];
const secretIdx = children.findIndex((c: any) => c.id === 'field_secret_mode');
const viewCountIdx = children.findIndex((c: any) => c.id === 'field_show_view_count');
expect(secretIdx).toBeGreaterThanOrEqual(0);
expect(viewCountIdx).toBeGreaterThanOrEqual(0);
expect(secretIdx).toBeLessThan(viewCountIdx);
});
it('기본 탭에 deprecated condition 속성이 없다 (if로 마이그레이션 완료)', () => {
const conditionNodes = findConditionNodes(tabBasic);
expect(conditionNodes, 'condition 속성 노드가 남아있음 (if로 교체 필요)').toHaveLength(0);
});
it('use_report 토글이 기본 탭에 있다', () => {
const fields = findFormFields(tabBasic);
expect(fields).toContain('use_report');
});
});
// ============================================================
// _tab_post.json 테스트
// ============================================================
describe('admin_board_form/_tab_post.json', () => {
it('파일이 유효한 JSON 구조이다', () => {
expect(tabPost).toBeDefined();
expect(tabPost.id).toBe('tab_content_post');
});
it('게시글 탭에 deprecated condition 속성이 없다 (if로 마이그레이션 완료)', () => {
const conditionNodes = findConditionNodes(tabPost);
expect(conditionNodes, 'condition 속성 노드가 남아있음 (if로 교체 필요)').toHaveLength(0);
});
it('답변글 서브섹션(post_reply_section)이 존재한다', () => {
const replySection = findById(tabPost, 'post_reply_section');
expect(replySection).toBeDefined();
});
it('답변글 서브섹션에 use_reply 토글과 max_reply_depth 필드가 있다', () => {
const replySection = findById(tabPost, 'post_reply_section');
expect(replySection).toBeDefined();
const fields = findFormFields(replySection);
expect(fields).toContain('use_reply');
expect(fields).toContain('max_reply_depth');
});
it('max_reply_depth 서브필드가 use_reply 토글 ON 시에만 표시된다 (if 조건)', () => {
// max_reply_depth_field Div 컨테이너에 if 조건이 적용됨
const maxReplyDepthField = findById(tabPost, 'max_reply_depth_field');
expect(maxReplyDepthField).toBeDefined();
expect(maxReplyDepthField.if).toBeDefined();
expect(maxReplyDepthField.if).toContain('use_reply');
});
it('댓글 서브섹션(post_comment_section)이 존재하고 use_comment 토글을 포함한다', () => {
const commentSection = findById(tabPost, 'post_comment_section');
expect(commentSection).toBeDefined();
const fields = findFormFields(commentSection);
expect(fields).toContain('use_comment');
});
it('댓글 서브 필드들이 use_comment ON 시에만 표시된다 (if 조건)', () => {
// use_comment_sub_fields 컨테이너에 if 조건이 적용됨
const card = findById(tabPost, 'section_post_card');
expect(card).toBeDefined();
// use_comment를 참조하는 if 속성을 가진 Div 컨테이너 확인
function findIfContaining(node: any, keyword: string): any[] {
const results: any[] = [];
if (node?.if?.includes(keyword)) results.push(node);
for (const c of node?.children ?? []) results.push(...findIfContaining(c, keyword));
return results;
}
const useCommentConditionals = findIfContaining(card, 'use_comment');
expect(useCommentConditionals.length).toBeGreaterThan(0);
});
it('post_reply_section이 post_comment_section보다 앞에 위치한다', () => {
// section_post_card 하위 children에서 순서 확인
const card = findById(tabPost, 'section_post_card');
expect(card).toBeDefined();
const children = card.children ?? [];
const replyIdx = children.findIndex((c: any) => c.id === 'post_reply_section');
const commentIdx = children.findIndex((c: any) => c.id === 'post_comment_section');
expect(replyIdx).toBeGreaterThanOrEqual(0);
expect(commentIdx).toBeGreaterThanOrEqual(0);
expect(replyIdx).toBeLessThan(commentIdx);
});
it('게시글 탭에 use_file_upload 토글이 있다', () => {
const fields = findFormFields(tabPost);
expect(fields).toContain('use_file_upload');
});
});
// ============================================================
// _tab_permissions.json 테스트 — board_manager_ids/board_step_ids uuid 참조
// ============================================================
/**
* JSON 트리에서 특정 id를 가진 TagInput 컴포넌트를 찾습니다.
* (_tab_permissions.json의 TagInput은 name prop 대신 id로 식별됨)
*/
function findTagInputById(node: any, id: string): any | null {
if (!node) return null;
if (node.name === 'TagInput' && node.id === id) return node;
for (const child of node.children ?? []) {
const found = findTagInputById(child, id);
if (found) return found;
}
for (const slotChildren of Object.values(node.slots ?? {})) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
const found = findTagInputById(child as any, id);
if (found) return found;
}
}
}
return null;
}
/**
* TagInput의 actions 배열에서 type: "change" 핸들러의 특정 form 필드 표현식을 반환합니다.
*/
function getChangeActionFormField(tagInput: any, fieldName: string): string | null {
if (!tagInput || !Array.isArray(tagInput.actions)) return null;
for (const action of tagInput.actions) {
if (action.type === 'change' && action.params?.form?.[fieldName]) {
return action.params.form[fieldName] as string;
}
}
return null;
}
describe('admin_board_form/_tab_permissions.json', () => {
it('파일이 유효한 JSON 구조이다', () => {
expect(tabPermissions).toBeDefined();
expect((tabPermissions as any).id).toBe('tab_content_permissions');
});
it('manager TagInput options 표현식이 u.id가 아닌 u.uuid를 value로 사용한다', () => {
const managerInput = findTagInputById(tabPermissions, 'permissions_managers_taginput');
expect(managerInput).not.toBeNull();
const options: string = managerInput.props.options ?? '';
expect(options).not.toMatch(/\bu\.id\b/);
expect(options).toContain('u.uuid');
});
it('manager change 핸들러의 board_managers 표현식이 u.uuid로 필터링한다', () => {
const managerInput = findTagInputById(tabPermissions, 'permissions_managers_taginput');
expect(managerInput).not.toBeNull();
const expr = getChangeActionFormField(managerInput, 'board_managers');
expect(expr).not.toBeNull();
expect(expr).not.toMatch(/\bu\.id\b/);
expect(expr).toContain('u.uuid');
});
it('step TagInput options 표현식이 u.uuid를 value로 사용한다', () => {
const stepInput = findTagInputById(tabPermissions, 'permissions_steps_taginput');
expect(stepInput).not.toBeNull();
const options: string = stepInput.props.options ?? '';
expect(options).not.toMatch(/\bu\.id\b/);
expect(options).toContain('u.uuid');
});
it('step change 핸들러의 board_steps 표현식이 u.uuid로 필터링한다', () => {
const stepInput = findTagInputById(tabPermissions, 'permissions_steps_taginput');
expect(stepInput).not.toBeNull();
const expr = getChangeActionFormField(stepInput, 'board_steps');
expect(expr).not.toBeNull();
expect(expr).not.toMatch(/\bu\.id\b/);
expect(expr).toContain('u.uuid');
});
});
@@ -0,0 +1,316 @@
/**
* 게시판 첨부파일 업로드 방식 변경 - 레이아웃 JSON 구조 검증 테스트
*
* @description
* - 관리자 첨부파일 partial: autoUpload=false, uploadTriggerEvent 설정 검증
* - 관리자 폼 저장 sequence: emitEvent가 apiCall 앞에 위치하는지 검증
* - 사용자 폼 동일 패턴 적용 검증
*
*/
import { describe, it, expect } from 'vitest';
// 레이아웃 JSON 임포트
import adminAttachments from '../../../layouts/admin/partials/admin_board_post_form/_attachments.json';
import adminPostForm from '../../../layouts/admin/admin_board_post_form.json';
// 사용자 폼은 templates 디렉토리에 있으므로 상대경로 사용
// JSON 파일은 직접 import (vitest가 자동 파싱)
import userPostForm from '../../../../../../templates/sirsoft-basic/layouts/partials/board/form/_post_form.json';
/**
* JSON 트리에서 특정 타입의 컴포넌트를 재귀적으로 찾습니다.
*/
function findComponentsByName(node: any, name: string): any[] {
const results: any[] = [];
if (!node) return results;
if (node.name === name) {
results.push(node);
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findComponentsByName(child, name));
}
}
// slots 내부도 검색
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
results.push(...findComponentsByName(child as any, name));
}
}
}
}
return results;
}
/**
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
*/
function findById(node: any, id: string): any | null {
if (!node) return null;
if (node.id === id) return node;
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
const found = findById(child, id);
if (found) return found;
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
const found = findById(child as any, id);
if (found) return found;
}
}
}
}
return null;
}
/**
* sequence 액션 내에서 handler 목록을 추출합니다.
*/
function getSequenceHandlers(actions: any[]): string[] {
for (const action of actions) {
if (action.handler === 'sequence' && action.actions) {
return action.actions.map((a: any) => a.handler);
}
}
return [];
}
describe('관리자 첨부파일 Partial (_attachments.json)', () => {
it('FileUploader에 autoUpload: false가 설정되어야 합니다', () => {
const fileUploaders = findComponentsByName(adminAttachments, 'FileUploader');
expect(fileUploaders.length).toBeGreaterThan(0);
const uploader = fileUploaders[0];
expect(uploader.props.autoUpload).toBe(false);
});
it('FileUploader에 uploadTriggerEvent가 설정되어야 합니다', () => {
const fileUploaders = findComponentsByName(adminAttachments, 'FileUploader');
const uploader = fileUploaders[0];
expect(uploader.props.uploadTriggerEvent).toBe('upload:board_attachments');
});
it('FileUploader에 uploadParams가 temp_key를 포함해야 합니다', () => {
const fileUploaders = findComponentsByName(adminAttachments, 'FileUploader');
const uploader = fileUploaders[0];
expect(uploader.props.uploadParams).toBeDefined();
expect(uploader.props.uploadParams.temp_key).toBeDefined();
expect(uploader.props.uploadParams.temp_key).toContain('_local.tempKey');
});
it('FileUploader의 upload URL에 쿼리스트링이 없어야 합니다', () => {
const fileUploaders = findComponentsByName(adminAttachments, 'FileUploader');
const uploader = fileUploaders[0];
const uploadUrl = uploader.props.apiEndpoints.upload;
// URL에 쿼리스트링 파라미터가 포함되지 않아야 함 (?.은 optional chaining이므로 허용)
expect(uploadUrl).not.toMatch(/\?[a-z_]+=/) // ?key= 형태의 쿼리스트링 금지
expect(uploadUrl).not.toContain('temp_key=');
expect(uploadUrl).not.toContain('post_id=');
});
});
describe('관리자 폼 저장 버튼 (admin_board_post_form.json)', () => {
it('header_save_button의 sequence에 emitEvent가 포함되어야 합니다', () => {
const headerButton = findById(adminPostForm, 'header_save_button');
expect(headerButton).not.toBeNull();
const handlers = getSequenceHandlers(headerButton.actions);
expect(handlers).toContain('emitEvent');
});
it('header_save_button의 emitEvent가 apiCall 앞에 위치해야 합니다', () => {
const headerButton = findById(adminPostForm, 'header_save_button');
const handlers = getSequenceHandlers(headerButton.actions);
const emitIndex = handlers.indexOf('emitEvent');
const apiCallIndex = handlers.indexOf('apiCall');
expect(emitIndex).toBeGreaterThan(-1);
expect(apiCallIndex).toBeGreaterThan(-1);
expect(emitIndex).toBeLessThan(apiCallIndex);
});
it('header_save_button의 emitEvent가 upload:board_attachments 이벤트를 발행해야 합니다', () => {
const headerButton = findById(adminPostForm, 'header_save_button');
const sequenceAction = headerButton.actions.find((a: any) => a.handler === 'sequence');
const emitAction = sequenceAction.actions.find((a: any) => a.handler === 'emitEvent');
expect(emitAction.params.event).toBe('upload:board_attachments');
});
it('footer_save_button의 sequence에 emitEvent가 포함되어야 합니다', () => {
const footerButton = findById(adminPostForm, 'footer_save_button');
expect(footerButton).not.toBeNull();
const handlers = getSequenceHandlers(footerButton.actions);
expect(handlers).toContain('emitEvent');
});
it('footer_save_button의 emitEvent가 apiCall 앞에 위치해야 합니다', () => {
const footerButton = findById(adminPostForm, 'footer_save_button');
const handlers = getSequenceHandlers(footerButton.actions);
const emitIndex = handlers.indexOf('emitEvent');
const apiCallIndex = handlers.indexOf('apiCall');
expect(emitIndex).toBeGreaterThan(-1);
expect(apiCallIndex).toBeGreaterThan(-1);
expect(emitIndex).toBeLessThan(apiCallIndex);
});
it('저장 sequence 순서: setState → emitEvent → apiCall', () => {
const headerButton = findById(adminPostForm, 'header_save_button');
const handlers = getSequenceHandlers(headerButton.actions);
expect(handlers[0]).toBe('setState');
expect(handlers[1]).toBe('emitEvent');
expect(handlers[2]).toBe('apiCall');
});
it('apiCall body에 temp_key가 포함되어야 합니다', () => {
const headerButton = findById(adminPostForm, 'header_save_button');
const sequenceAction = headerButton.actions.find((a: any) => a.handler === 'sequence');
const apiCallAction = sequenceAction.actions.find((a: any) => a.handler === 'apiCall');
expect(apiCallAction.params.body).toContain('temp_key');
expect(apiCallAction.params.body).toContain('_local.tempKey');
});
});
describe('사용자 폼 (_post_form.json)', () => {
it('FileUploader에 autoUpload: false가 설정되어야 합니다', () => {
const fileUploaders = findComponentsByName(userPostForm, 'FileUploader');
expect(fileUploaders.length).toBeGreaterThan(0);
const uploader = fileUploaders[0];
expect(uploader.props.autoUpload).toBe(false);
});
it('FileUploader에 uploadTriggerEvent가 설정되어야 합니다', () => {
const fileUploaders = findComponentsByName(userPostForm, 'FileUploader');
const uploader = fileUploaders[0];
expect(uploader.props.uploadTriggerEvent).toBe('upload:board_attachments');
});
it('FileUploader에 uploadParams가 temp_key를 포함해야 합니다', () => {
const fileUploaders = findComponentsByName(userPostForm, 'FileUploader');
const uploader = fileUploaders[0];
expect(uploader.props.uploadParams).toBeDefined();
expect(uploader.props.uploadParams.temp_key).toContain('_local.tempKey');
});
it('FileUploader의 upload URL에 쿼리스트링이 없어야 합니다', () => {
const fileUploaders = findComponentsByName(userPostForm, 'FileUploader');
const uploader = fileUploaders[0];
const uploadUrl = uploader.props.apiEndpoints.upload;
expect(uploadUrl).not.toContain('?');
expect(uploadUrl).not.toContain('temp_key=');
expect(uploadUrl).not.toContain('post_id=');
});
it('저장 버튼의 sequence에 emitEvent가 포함되어야 합니다', () => {
// 사용자 폼에서 저장 버튼은 children 마지막 영역에 위치
const allButtons = findComponentsByName(userPostForm, 'Button');
// sequence + apiCall 패턴을 가진 버튼 찾기
const saveButton = allButtons.find((btn: any) => {
if (!btn.actions) return false;
return btn.actions.some((a: any) =>
a.handler === 'sequence' &&
a.actions?.some((sa: any) => sa.handler === 'apiCall')
);
});
expect(saveButton).toBeDefined();
const handlers = getSequenceHandlers(saveButton.actions);
expect(handlers).toContain('emitEvent');
});
it('저장 버튼의 emitEvent가 apiCall 앞에 위치해야 합니다', () => {
const allButtons = findComponentsByName(userPostForm, 'Button');
const saveButton = allButtons.find((btn: any) => {
if (!btn.actions) return false;
return btn.actions.some((a: any) =>
a.handler === 'sequence' &&
a.actions?.some((sa: any) => sa.handler === 'apiCall')
);
});
const handlers = getSequenceHandlers(saveButton.actions);
const emitIndex = handlers.indexOf('emitEvent');
const apiCallIndex = handlers.indexOf('apiCall');
expect(emitIndex).toBeGreaterThan(-1);
expect(apiCallIndex).toBeGreaterThan(-1);
expect(emitIndex).toBeLessThan(apiCallIndex);
});
it('저장 sequence 순서: setState → emitEvent → apiCall', () => {
const allButtons = findComponentsByName(userPostForm, 'Button');
const saveButton = allButtons.find((btn: any) => {
if (!btn.actions) return false;
return btn.actions.some((a: any) =>
a.handler === 'sequence' &&
a.actions?.some((sa: any) => sa.handler === 'apiCall')
);
});
const handlers = getSequenceHandlers(saveButton.actions);
expect(handlers[0]).toBe('setState');
expect(handlers[1]).toBe('emitEvent');
expect(handlers[2]).toBe('apiCall');
});
});
describe('업로드 에러 시 isSaving 복구 (onUploadError)', () => {
it('관리자 첨부파일 onUploadError에서 isSaving: false를 설정해야 합니다', () => {
const fileUploaders = findComponentsByName(adminAttachments, 'FileUploader');
const uploader = fileUploaders[0];
const errorAction = uploader.actions.find((a: any) => a.event === 'onUploadError');
expect(errorAction).toBeDefined();
// sequence 내 setState에서 isSaving: false 확인
const setStateAction = errorAction.actions.find((a: any) => a.handler === 'setState');
expect(setStateAction).toBeDefined();
expect(setStateAction.params.isSaving).toBe(false);
});
it('사용자 폼 onUploadError에서 isSaving: false를 설정해야 합니다', () => {
const fileUploaders = findComponentsByName(userPostForm, 'FileUploader');
const uploader = fileUploaders[0];
const errorAction = uploader.actions.find((a: any) => a.event === 'onUploadError');
expect(errorAction).toBeDefined();
const setStateAction = errorAction.actions.find((a: any) => a.handler === 'setState');
expect(setStateAction).toBeDefined();
expect(setStateAction.params.isSaving).toBe(false);
});
it('관리자 첨부파일 uploadParams에 post_id가 없어야 합니다', () => {
const fileUploaders = findComponentsByName(adminAttachments, 'FileUploader');
const uploader = fileUploaders[0];
expect(uploader.props.uploadParams.post_id).toBeUndefined();
});
it('사용자 폼 uploadParams에 post_id가 없어야 합니다', () => {
const fileUploaders = findComponentsByName(userPostForm, 'FileUploader');
const uploader = fileUploaders[0];
expect(uploader.props.uploadParams.post_id).toBeUndefined();
});
});
@@ -0,0 +1,581 @@
/**
* 게시판 설정 메일 템플릿 탭/모달 레이아웃 JSON 구조 검증 테스트
*
* @description
* - 탭 Partial: _tab_mail_templates.json
* - 모달 Partial: _modal_mail_template_edit.json, _modal_mail_template_preview.json
*
* 검증 항목:
* - 탭 조건부 렌더링 (activeBoardSettingsTab === 'mail_templates')
* - 검색/필터 바 (검색타입 Select + Input + 검색/초기화 버튼)
* - 총 건수 + per_page 선택 + 페이지네이션
* - iteration 기반 템플릿 카드 목록 렌더링 (페이지네이션 데이터 경로)
* - 빈 상태 (검색결과 없음 / 데이터 없음 분기)
* - 카드 내 토글/편집/리셋 버튼 구조 및 API 호출
* - 변수 목록 조건부 표시 (variables.length > 0)
* - 타입 표시 분기 (is_default 여부)
* - navigate 기반 갱신 방식 (refetchDataSource 대신)
* - 편집 모달: blur_until_loaded, sticky footer, IIFE body, is_active toggle
* - 편집 버튼 상태 초기화 (templateErrors: null, isSaving: false)
* - 미리보기 모달 구조
*/
import { describe, it, expect } from 'vitest';
// 레이아웃 JSON 임포트
import mainLayout from '../../../layouts/admin/admin_board_settings.json';
import tabMailTemplates from '../../../layouts/admin/partials/admin_board_settings/_tab_mail_templates.json';
import modalEdit from '../../../layouts/admin/partials/admin_board_settings/_modal_mail_template_edit.json';
import modalPreview from '../../../layouts/admin/partials/admin_board_settings/_modal_mail_template_preview.json';
/**
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
*/
function findById(node: any, id: string): any | null {
if (!node) return null;
if (node.id === id) return node;
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
const found = findById(child, id);
if (found) return found;
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
const found = findById(child as any, id);
if (found) return found;
}
}
}
}
return null;
}
// ============================================================================
// 메인 레이아웃 (admin_board_settings.json) 내 메일 템플릿 연동 검증
// ============================================================================
describe('admin_board_settings.json 메일 템플릿 연동', () => {
it('data_sources에 boardMailTemplates가 정의되어 있다', () => {
const dataSources = mainLayout.data_sources;
const mailDs = dataSources.find((ds: any) => ds.id === 'boardMailTemplates');
expect(mailDs).toBeDefined();
expect(mailDs!.endpoint).toBe('/api/modules/sirsoft-board/admin/mail-templates');
expect(mailDs!.method).toBe('GET');
expect(mailDs!.auto_fetch).toBe(true);
});
it('data_sources params에 페이지네이션/검색 파라미터가 있다', () => {
const dataSources = mainLayout.data_sources;
const mailDs = dataSources.find((ds: any) => ds.id === 'boardMailTemplates');
expect(mailDs).toBeDefined();
expect(mailDs!.params).toBeDefined();
const paramsStr = JSON.stringify(mailDs!.params);
expect(paramsStr).toContain('query.page');
expect(paramsStr).toContain('query.per_page');
expect(paramsStr).toContain('query.search');
expect(paramsStr).toContain('query.search_type');
});
it('탭 네비게이션에 mail_templates 탭이 포함되어 있다', () => {
const slotContent = (mainLayout as any).slots?.content;
function findTabNav(node: any): any | null {
if (!node) return null;
if (node.name === 'TabNavigation') return node;
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
const found = findTabNav(child);
if (found) return found;
}
}
return null;
}
const tabNav = findTabNav({ children: slotContent });
expect(tabNav).toBeDefined();
const tabIds = tabNav.props.tabs.map((t: any) => t.id);
expect(tabIds).toContain('mail_templates');
});
it('slots에 _tab_mail_templates.json partial이 참조되어 있다', () => {
const slotContent = (mainLayout as any).slots;
const partials = JSON.stringify(slotContent);
expect(partials).toContain('_tab_mail_templates.json');
});
it('modals에 편집 모달 partial이 참조되어 있다', () => {
const modals = mainLayout.modals;
const modalsStr = JSON.stringify(modals);
expect(modalsStr).toContain('_modal_mail_template_edit.json');
});
it('modals에 미리보기 모달 partial이 참조되어 있다', () => {
const modals = mainLayout.modals;
const modalsStr = JSON.stringify(modals);
expect(modalsStr).toContain('_modal_mail_template_preview.json');
});
it('initLocal에 mailTemplateFilter가 있다', () => {
const initLocal = (mainLayout as any).initLocal;
expect(initLocal).toBeDefined();
expect(initLocal.mailTemplateFilter).toBeDefined();
expect(initLocal.mailTemplateFilter.search).toBe('');
expect(initLocal.mailTemplateFilter.searchType).toBe('all');
});
it('initLocal에 expandedMailTemplateIds가 빈 배열로 있다', () => {
const initLocal = (mainLayout as any).initLocal;
expect(initLocal.expandedMailTemplateIds).toBeDefined();
expect(initLocal.expandedMailTemplateIds).toEqual([]);
});
});
// ============================================================================
// 탭 Partial (_tab_mail_templates.json) 구조 검증
// ============================================================================
describe('_tab_mail_templates.json 구조 검증', () => {
it('partial 메타 정보가 올바르다', () => {
expect(tabMailTemplates.meta.is_partial).toBe(true);
});
it('mail_templates 탭일 때만 표시된다 (조건부 렌더링)', () => {
expect(tabMailTemplates.if).toContain('mail_templates');
expect(tabMailTemplates.if).toContain('_global.activeBoardSettingsTab');
});
it('헤더에 제목과 설명이 있다', () => {
const header = findById(tabMailTemplates, 'board_mail_templates_header');
expect(header).toBeDefined();
expect(header.type).toBe('basic');
});
// ========================================================================
// 검색/필터 바
// ========================================================================
describe('검색/필터 바', () => {
it('검색 바 영역이 존재한다', () => {
const search = findById(tabMailTemplates, 'board_mail_templates_search');
expect(search).toBeDefined();
});
it('검색 타입 Select (all/subject/body)가 있다', () => {
const search = findById(tabMailTemplates, 'board_mail_templates_search');
const searchStr = JSON.stringify(search);
expect(searchStr).toContain('search_type_all');
expect(searchStr).toContain('search_type_subject');
expect(searchStr).toContain('search_type_body');
});
it('검색 Input이 있다', () => {
const search = findById(tabMailTemplates, 'board_mail_templates_search');
const searchStr = JSON.stringify(search);
expect(searchStr).toContain('search_placeholder');
expect(searchStr).toContain('mailTemplateFilter');
});
it('검색 버튼이 navigate 핸들러를 사용한다', () => {
const search = findById(tabMailTemplates, 'board_mail_templates_search');
const searchStr = JSON.stringify(search);
expect(searchStr).toContain('btn_search');
expect(searchStr).toContain('"handler":"navigate"');
});
it('초기화 버튼이 setState + navigate sequence를 실행한다', () => {
const search = findById(tabMailTemplates, 'board_mail_templates_search');
const searchStr = JSON.stringify(search);
expect(searchStr).toContain('btn_reset_filter');
expect(searchStr).toContain('"handler":"sequence"');
expect(searchStr).toContain('"handler":"setState"');
expect(searchStr).toContain('"handler":"navigate"');
});
it('Enter 키로 검색이 가능하다', () => {
const search = findById(tabMailTemplates, 'board_mail_templates_search');
const searchStr = JSON.stringify(search);
expect(searchStr).toContain('"type":"keydown"');
expect(searchStr).toContain('"key":"Enter"');
});
});
// ========================================================================
// 정보 표시 줄 (총 건수 + per_page)
// ========================================================================
describe('정보 표시 줄', () => {
it('총 건수를 표시한다', () => {
const info = findById(tabMailTemplates, 'board_mail_templates_info');
expect(info).toBeDefined();
const infoStr = JSON.stringify(info);
expect(infoStr).toContain('total_count');
expect(infoStr).toContain('boardMailTemplates?.data?.pagination?.total');
});
it('per_page Select (10/20/50/100)가 있다', () => {
const info = findById(tabMailTemplates, 'board_mail_templates_info');
const infoStr = JSON.stringify(info);
expect(infoStr).toContain('per_page_label');
expect(infoStr).toContain('"10"');
expect(infoStr).toContain('"20"');
expect(infoStr).toContain('"50"');
expect(infoStr).toContain('"100"');
});
it('per_page 변경 시 navigate를 사용한다', () => {
const info = findById(tabMailTemplates, 'board_mail_templates_info');
const infoStr = JSON.stringify(info);
expect(infoStr).toContain('"handler":"navigate"');
expect(infoStr).toContain('per_page');
});
});
// ========================================================================
// 템플릿 카드 목록
// ========================================================================
describe('템플릿 카드 목록', () => {
it('iteration 소스가 페이지네이션 데이터 경로를 사용한다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
expect(card).toBeDefined();
expect(card.iteration).toBeDefined();
expect(card.iteration.source).toContain('boardMailTemplates?.data?.data');
expect(card.iteration.item_var).toBe('tpl');
expect(card.iteration.index_var).toBe('tplIdx');
});
it('is_default 여부로 타입 표시를 분기한다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('tpl.is_default');
// is_default인 경우 $t: 번역 키, 아닌 경우 raw type
expect(cardStr).toContain('sirsoft-board.admin.settings.mail_templates.types');
expect(cardStr).toContain('{{tpl.type}}');
});
it('카드에 활성/비활성 배지가 있다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('tpl.is_active');
});
it('카드에 로케일 제목 미리보기가 있다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('tpl.subject');
});
it('카드의 변수가 조건부로 표시된다 (variables.length > 0)', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('(tpl.variables ?? []).length > 0');
expect(cardStr).toContain('variable.key');
});
});
// ========================================================================
// 토글 버튼
// ========================================================================
describe('토글 버튼', () => {
it('PATCH /toggle-active API를 호출한다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('/api/modules/sirsoft-board/admin/mail-templates/');
expect(cardStr).toContain('toggle-active');
expect(cardStr).toContain('"method":"PATCH"');
});
it('성공 시 navigate를 사용한다 (refetchDataSource 대신)', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('"handler":"navigate"');
expect(cardStr).toContain('"mergeQuery":true');
});
});
// ========================================================================
// 편집 버튼
// ========================================================================
describe('편집 버튼', () => {
it('sequence 핸들러로 setState + openModal을 실행한다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('"handler":"setState"');
expect(cardStr).toContain('"editingTemplate"');
expect(cardStr).toContain('"handler":"openModal"');
expect(cardStr).toContain('modal_board_mail_template_edit');
});
it('편집 시 상태 초기화 (templateErrors: null, isSaving: false)', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('"templateErrors":null');
expect(cardStr).toContain('"isSaving":false');
});
});
// ========================================================================
// 리셋 버튼
// ========================================================================
describe('리셋 버튼', () => {
it('POST /reset API를 호출한다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('/reset');
expect(cardStr).toContain('"method":"POST"');
});
it('is_default가 아닐 때만 표시된다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
expect(cardStr).toContain('tpl.is_default');
});
it('성공 시 navigate를 사용한다', () => {
const card = findById(tabMailTemplates, 'board_template_card');
const cardStr = JSON.stringify(card);
// reset onSuccess에도 navigate가 사용됨
expect(cardStr).toContain('"handler":"navigate"');
});
});
// ========================================================================
// 본문 펼치기/접기 토글
// ========================================================================
describe('본문 펼치기/접기 토글', () => {
it('펼치기 버튼이 카드에 존재한다', () => {
const cardStr = JSON.stringify(tabMailTemplates);
expect(cardStr).toContain('board_btn_expand_');
expect(cardStr).toContain('fa-chevron-down');
expect(cardStr).toContain('fa-chevron-up');
});
it('펼치기 버튼이 expandedMailTemplateIds 상태를 토글한다', () => {
const cardStr = JSON.stringify(tabMailTemplates);
expect(cardStr).toContain('expandedMailTemplateIds');
expect(cardStr).toContain('.includes(tpl.id)');
expect(cardStr).toContain('.filter(');
});
it('본문 미리보기가 펼침 상태일 때만 표시된다', () => {
const cardStr = JSON.stringify(tabMailTemplates);
// body preview section with if condition
expect(cardStr).toContain('expandedMailTemplateIds');
expect(cardStr).toContain('tpl.body');
expect(cardStr).toContain('edit_modal.body');
});
});
// ========================================================================
// 빈 상태
// ========================================================================
describe('빈 상태', () => {
it('빈 상태 영역이 존재한다', () => {
const empty = findById(tabMailTemplates, 'board_mail_templates_empty');
expect(empty).toBeDefined();
expect(empty.if).toContain('boardMailTemplates?.data?.data');
expect(empty.if).toContain('length === 0');
});
it('검색결과 없음과 데이터 없음을 분기 표시한다', () => {
const empty = findById(tabMailTemplates, 'board_mail_templates_empty');
const emptyStr = JSON.stringify(empty);
expect(emptyStr).toContain('empty_no_results');
expect(emptyStr).toContain('empty_message');
expect(emptyStr).toContain('query.search');
});
});
// ========================================================================
// 페이지네이션
// ========================================================================
describe('페이지네이션', () => {
it('Pagination 컴포넌트가 존재한다', () => {
const pagination = findById(tabMailTemplates, 'board_mail_templates_pagination');
expect(pagination).toBeDefined();
const paginationStr = JSON.stringify(pagination);
expect(paginationStr).toContain('"name":"Pagination"');
});
it('currentPage와 totalPages가 올바른 데이터 경로를 사용한다', () => {
const pagination = findById(tabMailTemplates, 'board_mail_templates_pagination');
const paginationStr = JSON.stringify(pagination);
expect(paginationStr).toContain('boardMailTemplates?.data?.pagination?.current_page');
expect(paginationStr).toContain('boardMailTemplates?.data?.pagination?.last_page');
});
it('페이지 변경 시 navigate를 사용한다', () => {
const pagination = findById(tabMailTemplates, 'board_mail_templates_pagination');
const paginationStr = JSON.stringify(pagination);
expect(paginationStr).toContain('"handler":"navigate"');
expect(paginationStr).toContain('mergeQuery');
});
});
});
// ============================================================================
// 편집 모달 (_modal_mail_template_edit.json) 구조 검증
// ============================================================================
describe('_modal_mail_template_edit.json 구조 검증', () => {
it('모달 ID가 올바르다', () => {
expect(modalEdit.id).toBe('modal_board_mail_template_edit');
});
it('Modal 컴포넌트를 사용한다', () => {
expect(modalEdit.name).toBe('Modal');
expect(modalEdit.type).toBe('composite');
});
it('모달 크기가 xl이다', () => {
expect(modalEdit.props.size).toBe('xl');
});
it('closeOnOverlayClick이 false이다', () => {
expect(modalEdit.props.closeOnOverlayClick).toBe(false);
});
// ========================================================================
// blur_until_loaded
// ========================================================================
it('폼 컨테이너에 blur_until_loaded가 적용되어 있다', () => {
const container = findById(modalEdit, 'board_edit_form_container');
expect(container).toBeDefined();
expect(container.blur_until_loaded).toBeDefined();
expect(container.blur_until_loaded).toContain('isSaving');
});
// ========================================================================
// sticky footer
// ========================================================================
it('footer가 sticky 스타일을 가진다', () => {
const footer = findById(modalEdit, 'board_edit_modal_footer');
expect(footer).toBeDefined();
expect(footer.props.className).toContain('sticky');
expect(footer.props.className).toContain('bottom-0');
expect(footer.props.className).toContain('bg-white');
expect(footer.props.className).toContain('dark:bg-gray-800');
});
it('알림 유형 라벨이 읽기 전용으로 표시된다', () => {
const editStr = JSON.stringify(modalEdit);
expect(editStr).toContain('_local.editingTemplate?.type');
});
it('한국어/영어 제목 입력 필드가 있다', () => {
const subjectKo = findById(modalEdit, 'board_edit_subject_ko');
const subjectEn = findById(modalEdit, 'board_edit_subject_en');
expect(subjectKo).toBeDefined();
expect(subjectEn).toBeDefined();
});
it('한국어/영어 본문 에디터가 있다', () => {
const bodyKo = findById(modalEdit, 'board_edit_body_ko');
const bodyEn = findById(modalEdit, 'board_edit_body_en');
expect(bodyKo).toBeDefined();
expect(bodyEn).toBeDefined();
});
// ========================================================================
// is_active Toggle
// ========================================================================
it('is_active Toggle이 있다', () => {
const toggle = findById(modalEdit, 'board_edit_is_active_toggle');
expect(toggle).toBeDefined();
const toggleStr = JSON.stringify(toggle);
expect(toggleStr).toContain('Toggle');
expect(toggleStr).toContain('editingTemplate?.is_active');
expect(toggleStr).toContain('is_active_label');
});
// ========================================================================
// 변수 정보 조건부 표시
// ========================================================================
it('변수 정보가 variables.length > 0일 때만 표시된다', () => {
const varInfo = findById(modalEdit, 'board_edit_variables_info');
expect(varInfo).toBeDefined();
expect(varInfo.if).toContain('editingTemplate?.variables');
expect(varInfo.if).toContain('length > 0');
});
// ========================================================================
// 저장 버튼 (함수형 IIFE body)
// ========================================================================
it('저장 버튼이 PUT API를 호출한다', () => {
const editStr = JSON.stringify(modalEdit);
expect(editStr).toContain('/api/modules/sirsoft-board/admin/mail-templates/');
expect(editStr).toContain('"method":"PUT"');
});
it('저장 body가 함수형 IIFE 패턴을 사용한다', () => {
const editStr = JSON.stringify(modalEdit);
expect(editStr).toContain('(function()');
expect(editStr).toContain('tpl.subject');
expect(editStr).toContain('tpl.body');
expect(editStr).toContain('tpl.is_active');
});
it('저장 성공 시 closeModal + navigate를 실행한다', () => {
const editStr = JSON.stringify(modalEdit);
expect(editStr).toContain('"handler":"closeModal"');
expect(editStr).toContain('"handler":"navigate"');
expect(editStr).toContain('"mergeQuery":true');
});
it('저장 전 isSaving=true, templateErrors=null 설정', () => {
const editStr = JSON.stringify(modalEdit);
// sequence 첫 번째 액션에서 isSaving: true 설정
expect(editStr).toContain('"isSaving":true');
expect(editStr).toContain('"templateErrors":null');
});
it('저장 실패 시 에러를 templateErrors에 저장한다', () => {
const editStr = JSON.stringify(modalEdit);
expect(editStr).toContain('error.errors');
expect(editStr).toContain('"isSaving":false');
});
it('취소 버튼이 closeModal을 실행한다', () => {
const editStr = JSON.stringify(modalEdit);
expect(editStr).toContain('"handler":"closeModal"');
});
});
// ============================================================================
// 미리보기 모달 (_modal_mail_template_preview.json) 구조 검증
// ============================================================================
describe('_modal_mail_template_preview.json 구조 검증', () => {
it('모달 ID가 올바르다', () => {
expect(modalPreview.id).toBe('modal_board_mail_template_preview');
});
it('Modal 컴포넌트를 사용한다', () => {
expect(modalPreview.name).toBe('Modal');
expect(modalPreview.type).toBe('composite');
});
it('미리보기 제목이 표시된다', () => {
const previewStr = JSON.stringify(modalPreview);
expect(previewStr).toContain('preview');
});
});
@@ -0,0 +1,851 @@
// @vitest-environment jsdom
/**
* @file board-index-category-filter.test.tsx
* @description 게시판 게시글 목록 - 카테고리 필터 검색 기능 테스트
*
* 검증 항목:
* 1. 카테고리 필터 표시/미표시 조건
* 2. API params에 category 포함
* 3. 페이지네이션에 category query 유지
* 4. 빈 결과 조건 분기 (카테고리 필터 반영)
*/
import '@testing-library/jest-dom';
import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
// G7Core 전역 모킹
(globalThis as any).window = globalThis.window || globalThis;
(window as any).G7Core = (window as any).G7Core || {
createChangeEvent: vi.fn((data: { checked?: boolean; name?: string; value?: any }) => ({
target: {
checked: data.checked ?? false,
name: data.name ?? '',
value: data.value ?? (data.checked ? 'on' : 'off'),
type: 'checkbox',
},
currentTarget: {
checked: data.checked ?? false,
name: data.name ?? '',
value: data.value ?? (data.checked ? 'on' : 'off'),
type: 'checkbox',
},
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
})),
state: {
get: vi.fn(),
set: vi.fn(),
update: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
},
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
modal: { open: vi.fn(), close: vi.fn() },
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()), off: vi.fn() },
t: (key: string) => key,
};
// ============================================================
// 테스트용 컴포넌트 정의
// ============================================================
const TestDiv: React.FC<{
className?: string;
children?: React.ReactNode;
'data-testid'?: string;
}> = ({ className, children, 'data-testid': testId }) => (
<div className={className} data-testid={testId}>
{children}
</div>
);
const TestButton: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
onClick?: () => void;
'data-testid'?: string;
}> = ({ className, children, text, onClick, 'data-testid': testId }) => (
<button className={className} onClick={onClick} data-testid={testId}>
{children || text}
</button>
);
const TestSelect: React.FC<{
value?: string | number;
className?: string;
options?: Array<{ value: string | number; label: string }>;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
'data-testid'?: string;
}> = ({ value, className, options, onChange, 'data-testid': testId }) => (
<select
value={value}
className={className}
onChange={onChange}
data-testid={testId}
>
{options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
const TestSpan: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => (
<span className={className}>{children || text}</span>
);
const TestH1: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => (
<h1 className={className}>{children || text}</h1>
);
const TestH2: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => (
<h2 className={className}>{children || text}</h2>
);
const TestP: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => (
<p className={className}>{children || text}</p>
);
const TestIcon: React.FC<{
name?: string;
size?: string;
className?: string;
}> = ({ name, size, className }) => (
<i className={className} data-icon={name} data-size={size} />
);
const TestContainer: React.FC<{
className?: string;
children?: React.ReactNode;
}> = ({ className, children }) => (
<div className={className} data-testid="container">
{children}
</div>
);
const TestSearchBar: React.FC<{
name?: string;
placeholder?: string;
value?: string;
showButton?: boolean;
className?: string;
onSubmit?: (e: React.FormEvent) => void;
'data-testid'?: string;
}> = ({ name, placeholder, value, className, onSubmit, 'data-testid': testId }) => (
<form onSubmit={onSubmit} data-testid={testId || 'search-bar'}>
<input name={name} placeholder={placeholder} defaultValue={value} />
</form>
);
const TestPagination: React.FC<{
currentPage?: number;
totalPages?: number;
className?: string;
onPageChange?: (page: number) => void;
}> = ({ currentPage, totalPages, className, onPageChange }) => (
<nav className={className} data-testid="pagination">
<span>
Page {currentPage} of {totalPages}
</span>
{onPageChange && (
<button onClick={() => onPageChange((currentPage ?? 1) + 1)}>Next</button>
)}
</nav>
);
// ============================================================
// 컴포넌트 레지스트리 설정
// ============================================================
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Fragment: { component: ({ children }: any) => <>{children}</>, metadata: { name: 'Fragment', type: 'layout' } },
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Button: {
component: TestButton,
metadata: { name: 'Button', type: 'basic' },
},
Select: {
component: TestSelect,
metadata: { name: 'Select', type: 'basic' },
},
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
H1: { component: TestH1, metadata: { name: 'H1', type: 'basic' } },
H2: { component: TestH2, metadata: { name: 'H2', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Container: {
component: TestContainer,
metadata: { name: 'Container', type: 'layout' },
},
SearchBar: {
component: TestSearchBar,
metadata: { name: 'SearchBar', type: 'composite' },
},
Pagination: {
component: TestPagination,
metadata: { name: 'Pagination', type: 'composite' },
},
};
return registry;
}
// ============================================================
// 레이아웃 JSON (카테고리 필터 관련 부분만 발췌)
// ============================================================
/**
* 카테고리가 있는 게시판의 게시글 목록 레이아웃 (간략화)
* 실제 index.json에서 카테고리 필터 관련 기능만 추출
*/
const categoryFilterLayoutJson = {
version: '1.0.0',
layout_name: 'board_index_category_test',
data_sources: [
{
id: 'posts',
type: 'api',
endpoint: '/api/modules/sirsoft-board/boards/test-board/posts',
method: 'GET',
params: {
page: '{{query.page ?? 1}}',
search: "{{query.search ?? ''}}",
category: "{{query.category ?? ''}}",
},
auto_fetch: true,
auth_required: true,
refetchOnMount: true,
},
],
components: [
{
comment: '메인 컨테이너',
type: 'layout',
name: 'Container',
if: '{{posts?.data?.board}}',
children: [
{
comment: '헤더',
type: 'basic',
name: 'H1',
props: {
'data-testid': 'board-title',
},
text: "{{posts?.data?.board?.name ?? ''}}",
},
{
comment: '필터 바 (카테고리 필터 + 검색)',
type: 'basic',
name: 'Div',
props: {
className:
'flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 mb-4',
'data-testid': 'filter-bar',
},
children: [
{
comment: '카테고리 필터 (카테고리가 있을 때만 표시)',
type: 'basic',
name: 'Select',
if: '{{posts?.data?.board?.categories && posts?.data?.board?.categories.length > 0}}',
props: {
value: "{{query.category || 'all'}}",
className:
'w-full sm:w-48 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm',
options:
"{{[{value:'all', label:'전체 카테고리'}].concat((posts?.data?.board?.categories ?? []).map(function(c){return{value:c,label:c};}))}}",
'data-testid': 'category-filter',
},
actions: [
{
type: 'change',
handler: 'navigate',
params: {
path: '/board/{{route.slug}}',
mergeQuery: true,
query: {
category:
"{{$event.target.value === 'all' ? '' : $event.target.value}}",
page: 1,
},
},
},
],
},
{
comment: '검색 바',
type: 'basic',
name: 'Div',
props: { className: 'flex justify-end' },
children: [
{
type: 'composite',
name: 'SearchBar',
props: {
name: 'search',
placeholder: '검색',
value: "{{query.search || ''}}",
showButton: false,
className: 'w-full sm:w-64',
},
actions: [
{
type: 'submit',
handler: 'navigate',
params: {
path: '/board/{{route.slug}}',
mergeQuery: true,
query: {
search: '{{$event.target.search.value}}',
page: 1,
},
},
},
],
},
],
},
],
},
{
comment: '게시글 없음 (검색/필터 아닐 때)',
type: 'basic',
name: 'Div',
if: "{{posts?.data?.board?.slug && !query.search && !query.category && (!query.page || Number(query.page) <= 1) && posts?.data?.pagination?.total === 0}}",
props: { 'data-testid': 'empty-no-filter' },
children: [
{
type: 'basic',
name: 'P',
text: '등록된 게시글이 없습니다',
},
],
},
{
comment: '검색/카테고리 결과 없음',
type: 'basic',
name: 'Div',
if: '{{posts?.data?.board?.slug && (query.search || query.category) && posts?.data?.pagination?.total === 0}}',
props: { 'data-testid': 'empty-with-filter' },
children: [
{
type: 'basic',
name: 'P',
text: '검색 결과가 없습니다',
},
{
type: 'basic',
name: 'Button',
text: '검색 초기화',
props: { 'data-testid': 'clear-filter-btn' },
actions: [
{
type: 'click',
handler: 'navigate',
params: { path: '/board/{{route.slug}}' },
},
],
},
],
},
{
comment: '페이지네이션',
type: 'composite',
name: 'Pagination',
props: {
currentPage: '{{posts?.data?.pagination?.current_page ?? 1}}',
totalPages: '{{posts?.data?.pagination?.last_page ?? 1}}',
},
actions: [
{
event: 'onPageChange',
type: 'change',
handler: 'navigate',
params: {
path: '/board/{{route?.slug}}',
mergeQuery: true,
query: {
page: '{{$args[0]}}',
category: '{{query.category}}',
},
},
},
],
},
],
},
],
};
// ============================================================
// 테스트 데이터
// ============================================================
/** 카테고리가 있는 게시판 + 게시글이 있는 응답 */
const boardWithCategoriesResponse = {
data: {
board: {
id: 1,
slug: 'test-board',
name: '테스트 게시판',
type: 'basic',
categories: ['공지사항', '자유게시판', '질문답변'],
show_category: true,
},
data: [
{ id: 1, title: '첫 번째 글', category: '공지사항' },
{ id: 2, title: '두 번째 글', category: '자유게시판' },
],
pagination: { current_page: 1, last_page: 3, total: 25 },
permissions: { posts_write: true },
},
};
/** 카테고리가 없는 게시판 응답 */
const boardWithoutCategoriesResponse = {
data: {
board: {
id: 2,
slug: 'no-cat-board',
name: '카테고리 없는 게시판',
type: 'basic',
categories: [],
show_category: false,
},
data: [{ id: 1, title: '글1' }],
pagination: { current_page: 1, last_page: 1, total: 1 },
permissions: { posts_write: true },
},
};
/** 빈 결과 (필터 없이) */
const emptyBoardResponse = {
data: {
board: {
id: 1,
slug: 'test-board',
name: '테스트 게시판',
type: 'basic',
categories: ['공지사항'],
show_category: true,
},
data: [],
pagination: { current_page: 1, last_page: 1, total: 0 },
permissions: { posts_write: true },
},
};
// ============================================================
// 테스트
// ============================================================
describe('게시판 목록 - 카테고리 필터', () => {
let registry: ComponentRegistry;
beforeEach(() => {
registry = setupTestRegistry();
});
describe('카테고리 필터 표시/미표시 조건', () => {
it('카테고리가 있는 게시판에서 카테고리 Select가 표시된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
initialData: { posts: boardWithCategoriesResponse },
});
await testUtils.render();
// 카테고리 필터 Select가 존재해야 함
const categorySelect = screen.queryByTestId('category-filter');
expect(categorySelect).toBeInTheDocument();
testUtils.cleanup();
});
it('카테고리가 없는 게시판에서 카테고리 Select가 표시되지 않는다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'no-cat-board' },
initialData: { posts: boardWithoutCategoriesResponse },
});
await testUtils.render();
// 카테고리 필터 Select가 없어야 함
const categorySelect = screen.queryByTestId('category-filter');
expect(categorySelect).not.toBeInTheDocument();
testUtils.cleanup();
});
it('categories가 빈 배열인 게시판에서 카테고리 Select가 표시되지 않는다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'no-cat-board' },
initialData: {
posts: {
data: {
...boardWithoutCategoriesResponse.data,
board: {
...boardWithoutCategoriesResponse.data.board,
categories: [],
},
},
},
},
});
await testUtils.render();
const categorySelect = screen.queryByTestId('category-filter');
expect(categorySelect).not.toBeInTheDocument();
testUtils.cleanup();
});
});
describe('API params에 category 포함', () => {
it('data_sources params에 category 파라미터가 포함된다', () => {
// 레이아웃 JSON 구조 검증 (정적 분석)
const dataSource = categoryFilterLayoutJson.data_sources[0];
expect(dataSource.params).toHaveProperty('category');
expect(dataSource.params.category).toBe("{{query.category ?? ''}}");
});
it('query.category가 있을 때 API 요청에 category가 포함된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
queryParams: { category: '공지사항' },
initialData: { posts: boardWithCategoriesResponse },
});
testUtils.mockApi('posts', {
response: boardWithCategoriesResponse.data,
});
await testUtils.render();
// category Select의 value가 query.category와 일치해야 함
const categorySelect = screen.queryByTestId('category-filter');
expect(categorySelect).toBeInTheDocument();
testUtils.cleanup();
});
});
describe('카테고리 필터 변경 시 네비게이션', () => {
it('카테고리 선택 시 navigate 핸들러가 호출된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
initialData: { posts: boardWithCategoriesResponse },
});
await testUtils.render();
// 카테고리 변경 액션 트리거
await testUtils.triggerAction({
type: 'change',
handler: 'navigate',
params: {
path: '/board/test-board',
mergeQuery: true,
query: {
category: '공지사항',
page: 1,
},
},
});
// navigate가 호출되었는지 확인
expect(testUtils.mockNavigate).toHaveBeenCalled();
expect(testUtils.getNavigationHistory()).toContain('/board/test-board');
testUtils.cleanup();
});
it('"전체 카테고리" 선택 시 category 값이 빈 문자열이어야 한다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
queryParams: { category: '공지사항' },
initialData: { posts: boardWithCategoriesResponse },
});
await testUtils.render();
// "전체" 선택 시 category='' 로 네비게이션
await testUtils.triggerAction({
type: 'change',
handler: 'navigate',
params: {
path: '/board/test-board',
mergeQuery: true,
query: {
category: '', // 'all' 선택 시 빈 문자열
page: 1,
},
},
});
expect(testUtils.mockNavigate).toHaveBeenCalled();
testUtils.cleanup();
});
});
describe('페이지네이션에 category query 유지', () => {
it('페이지네이션 액션 query에 category가 포함된다', () => {
// 레이아웃 JSON 구조 검증 (정적 분석)
const container = categoryFilterLayoutJson.components[0];
const pagination = container.children?.find(
(c: any) => c.name === 'Pagination'
);
expect(pagination).toBeDefined();
expect(pagination?.actions?.[0]?.params?.query).toHaveProperty(
'category'
);
expect(pagination?.actions?.[0]?.params?.query?.category).toBe(
'{{query.category}}'
);
});
it('페이지 변경 시 카테고리 필터가 유지된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
queryParams: { category: '공지사항', page: '1' },
initialData: { posts: boardWithCategoriesResponse },
});
await testUtils.render();
// 페이지 변경 트리거
await testUtils.triggerAction({
type: 'change',
handler: 'navigate',
params: {
path: '/board/test-board',
mergeQuery: true,
query: {
page: 2,
category: '공지사항', // 카테고리 유지
},
},
});
expect(testUtils.mockNavigate).toHaveBeenCalled();
testUtils.cleanup();
});
});
describe('빈 결과 조건 분기', () => {
it('필터 없이 게시글이 0건일 때 "게시글 없음" 메시지가 표시된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
// "게시글 없음" (필터 없을 때)
const emptyNoFilter = screen.queryByTestId('empty-no-filter');
expect(emptyNoFilter).toBeInTheDocument();
// "검색 결과 없음"은 표시되지 않아야 함
const emptyWithFilter = screen.queryByTestId('empty-with-filter');
expect(emptyWithFilter).not.toBeInTheDocument();
testUtils.cleanup();
});
it('카테고리 필터 결과가 0건일 때 "검색 결과 없음" 메시지가 표시된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
queryParams: { category: '공지사항' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
// "검색 결과 없음" (카테고리 필터 있을 때)
const emptyWithFilter = screen.queryByTestId('empty-with-filter');
expect(emptyWithFilter).toBeInTheDocument();
// "게시글 없음"은 표시되지 않아야 함 (category 필터가 있으므로)
const emptyNoFilter = screen.queryByTestId('empty-no-filter');
expect(emptyNoFilter).not.toBeInTheDocument();
testUtils.cleanup();
});
it('검색어가 있고 결과가 0건일 때 "검색 결과 없음" 메시지가 표시된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
queryParams: { search: '없는글' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
const emptyWithFilter = screen.queryByTestId('empty-with-filter');
expect(emptyWithFilter).toBeInTheDocument();
const emptyNoFilter = screen.queryByTestId('empty-no-filter');
expect(emptyNoFilter).not.toBeInTheDocument();
testUtils.cleanup();
});
it('카테고리 + 검색 동시 필터 결과가 0건일 때 "검색 결과 없음" 메시지가 표시된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
queryParams: { category: '공지사항', search: '없는글' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
const emptyWithFilter = screen.queryByTestId('empty-with-filter');
expect(emptyWithFilter).toBeInTheDocument();
testUtils.cleanup();
});
it('초기화 버튼 클릭 시 모든 필터가 해제된다', async () => {
const testUtils = createLayoutTest(categoryFilterLayoutJson, {
componentRegistry: registry,
translations: { board: { filter: { category_all: '전체 카테고리' } } },
routeParams: { slug: 'test-board' },
queryParams: { category: '공지사항' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
// 초기화 버튼 트리거 (path만 지정 → 쿼리 초기화)
await testUtils.triggerAction({
type: 'click',
handler: 'navigate',
params: {
path: '/board/test-board',
},
});
expect(testUtils.mockNavigate).toHaveBeenCalled();
// path에 query가 없으므로 필터 해제
expect(testUtils.getNavigationHistory()).toContain('/board/test-board');
testUtils.cleanup();
});
});
describe('레이아웃 JSON 구조 정적 검증', () => {
it('data_sources에 category 파라미터가 올바르게 정의되어 있다', () => {
const ds = categoryFilterLayoutJson.data_sources[0];
expect(ds.params.category).toBe("{{query.category ?? ''}}");
});
it('Select 컴포넌트 타입이 basic이다', () => {
const container = categoryFilterLayoutJson.components[0];
const filterBar = container.children?.find(
(c: any) => c.comment === '필터 바 (카테고리 필터 + 검색)'
);
const select = filterBar?.children?.find(
(c: any) => c.name === 'Select'
);
expect(select).toBeDefined();
expect(select?.type).toBe('basic');
});
it('Select의 change 액션이 navigate 핸들러를 사용한다', () => {
const container = categoryFilterLayoutJson.components[0];
const filterBar = container.children?.find(
(c: any) => c.comment === '필터 바 (카테고리 필터 + 검색)'
);
const select = filterBar?.children?.find(
(c: any) => c.name === 'Select'
);
const action = select?.actions?.[0];
expect(action?.type).toBe('change');
expect(action?.handler).toBe('navigate');
expect(action?.params?.mergeQuery).toBe(true);
expect(action?.params?.query?.page).toBe(1);
});
it('Select의 if 조건이 categories 존재 여부를 확인한다', () => {
const container = categoryFilterLayoutJson.components[0];
const filterBar = container.children?.find(
(c: any) => c.comment === '필터 바 (카테고리 필터 + 검색)'
);
const select = filterBar?.children?.find(
(c: any) => c.name === 'Select'
);
expect(select?.if).toContain('categories');
expect(select?.if).toContain('length > 0');
});
it('빈 결과 조건에 !query.category가 포함되어 있다', () => {
const container = categoryFilterLayoutJson.components[0];
const emptyNoFilter = container.children?.find(
(c: any) => c.props?.['data-testid'] === 'empty-no-filter'
);
expect(emptyNoFilter?.if).toContain('!query.category');
});
it('검색 결과 없음 조건에 query.category가 포함되어 있다', () => {
const container = categoryFilterLayoutJson.components[0];
const emptyWithFilter = container.children?.find(
(c: any) => c.props?.['data-testid'] === 'empty-with-filter'
);
expect(emptyWithFilter?.if).toContain('query.category');
});
});
});
@@ -0,0 +1,733 @@
// @vitest-environment jsdom
/**
* @file board-index-empty-states.test.tsx
* @description 게시판 목록 - 빈 상태, 로딩/오류, 글쓰기 버튼 렌더링 테스트
*
* 검증 항목:
* 1. 빈 상태 3종 (빈 페이지 / 게시글 없음 / 검색 결과 없음) 조건부 렌더링
* 2. 로딩 스켈레톤 / 오류 페이지 조건부 렌더링
* 3. 글쓰기 버튼 권한별 분기 (allowed / no_permission / not_logged_in)
*/
import '@testing-library/jest-dom';
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
// G7Core 전역 모킹
(globalThis as any).window = globalThis.window || globalThis;
(window as any).G7Core = (window as any).G7Core || {
createChangeEvent: vi.fn((data: { checked?: boolean; name?: string; value?: any }) => ({
target: {
checked: data.checked ?? false,
name: data.name ?? '',
value: data.value ?? (data.checked ? 'on' : 'off'),
type: 'checkbox',
},
currentTarget: {
checked: data.checked ?? false,
name: data.name ?? '',
value: data.value ?? (data.checked ? 'on' : 'off'),
type: 'checkbox',
},
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
})),
state: {
get: vi.fn(),
set: vi.fn(),
update: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
},
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
modal: { open: vi.fn(), close: vi.fn() },
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()), off: vi.fn() },
t: (key: string) => key,
};
// ============================================================
// 테스트용 컴포넌트 정의
// ============================================================
const TestDiv: React.FC<{
className?: string;
children?: React.ReactNode;
'data-testid'?: string;
}> = ({ className, children, 'data-testid': testId }) => (
<div className={className} data-testid={testId}>
{children}
</div>
);
const TestButton: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
type?: string;
onClick?: () => void;
'data-testid'?: string;
}> = ({ className, children, text, onClick, 'data-testid': testId }) => (
<button className={className} onClick={onClick} data-testid={testId}>
{children || text}
</button>
);
const TestSpan: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => (
<span className={className}>{children || text}</span>
);
const TestH1: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => (
<h1 className={className}>{children || text}</h1>
);
const TestH2: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => (
<h2 className={className}>{children || text}</h2>
);
const TestP: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => (
<p className={className}>{children || text}</p>
);
const TestIcon: React.FC<{
name?: string;
size?: string;
className?: string;
}> = ({ name, size, className }) => (
<i className={className} data-icon={name} data-size={size} />
);
const TestContainer: React.FC<{
className?: string;
children?: React.ReactNode;
}> = ({ className, children }) => (
<div className={className} data-testid="container">
{children}
</div>
);
const TestSearchBar: React.FC<{
name?: string;
placeholder?: string;
value?: string;
showButton?: boolean;
className?: string;
onSubmit?: (e: React.FormEvent) => void;
}> = ({ name, placeholder, value, className, onSubmit }) => (
<form onSubmit={onSubmit} data-testid="search-bar">
<input name={name} placeholder={placeholder} defaultValue={value} />
</form>
);
const TestPagination: React.FC<{
currentPage?: number;
totalPages?: number;
className?: string;
onPageChange?: (page: number) => void;
}> = ({ currentPage, totalPages, className }) => (
<nav className={className} data-testid="pagination">
<span>
Page {currentPage} of {totalPages}
</span>
</nav>
);
const TestSelect: React.FC<{
value?: string | number;
className?: string;
options?: Array<{ value: string | number; label: string }>;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}> = ({ value, className, options, onChange }) => (
<select value={value} className={className} onChange={onChange}>
{options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
// ============================================================
// 컴포넌트 레지스트리 설정
// ============================================================
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Fragment: { component: ({ children }: any) => <>{children}</>, metadata: { name: 'Fragment', type: 'layout' } },
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
H1: { component: TestH1, metadata: { name: 'H1', type: 'basic' } },
H2: { component: TestH2, metadata: { name: 'H2', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Select: { component: TestSelect, metadata: { name: 'Select', type: 'basic' } },
Container: { component: TestContainer, metadata: { name: 'Container', type: 'layout' } },
SearchBar: { component: TestSearchBar, metadata: { name: 'SearchBar', type: 'composite' } },
Pagination: { component: TestPagination, metadata: { name: 'Pagination', type: 'composite' } },
};
return registry;
}
// ============================================================
// 레이아웃 JSON (빈 상태 + 로딩/오류 + 글쓰기 버튼 검증용)
// ============================================================
/**
* 리팩토링된 index.json 구조의 핵심 부분을 재현
* partial 분리된 내용을 인라인으로 포함 (테스트 편의)
*/
const boardIndexLayoutJson = {
version: '1.0.0',
layout_name: 'board_index_empty_states_test',
data_sources: [
{
id: 'posts',
type: 'api',
endpoint: '/api/modules/sirsoft-board/boards/test-board/posts',
method: 'GET',
params: {
page: '{{query.page ?? 1}}',
search: "{{query.search ?? ''}}",
category: "{{query.category ?? ''}}",
},
auto_fetch: true,
auth_required: true,
},
],
components: [
{
comment: '로딩 스켈레톤 (오류 없을 때만)',
type: 'layout',
name: 'Container',
if: '{{!posts?.data?.board && !_global.hasError}}',
props: { 'data-testid': 'loading-skeleton' },
children: [
{
type: 'basic',
name: 'P',
text: '게시글을 불러오는 중입니다...',
},
],
},
{
comment: '오류 페이지',
type: 'layout',
name: 'Container',
if: '{{_global.hasError}}',
props: { 'data-testid': 'error-page' },
children: [
{
type: 'basic',
name: 'H2',
props: { 'data-testid': 'error-title' },
text: '일시적인 오류가 발생했습니다',
},
{
type: 'basic',
name: 'Button',
props: { 'data-testid': 'reload-btn' },
text: '새로고침',
actions: [{ type: 'click', handler: 'refresh' }],
},
],
},
{
comment: '메인 컨테이너 (데이터 로드 완료 시)',
type: 'layout',
name: 'Container',
if: '{{posts?.data?.board}}',
children: [
{
type: 'basic',
name: 'H1',
props: { 'data-testid': 'board-title' },
text: "{{posts?.data?.board?.name ?? ''}}",
},
{
comment: '글쓰기 버튼',
type: 'basic',
name: 'Button',
props: { 'data-testid': 'write-btn' },
actions: [
{
type: 'click',
handler: 'switch',
params: {
value:
"{{posts?.data?.permissions?.posts_write ? 'allowed' : (_global.currentUser?.uuid ? 'no_permission' : 'not_logged_in')}}",
},
cases: {
allowed: {
handler: 'navigate',
params: { path: '/board/{{route.slug}}/write' },
},
no_permission: {
handler: 'toast',
params: { type: 'warning', message: '글쓰기 권한이 없습니다' },
},
not_logged_in: {
handler: 'sequence',
actions: [
{
handler: 'toast',
params: { type: 'info', message: '글을 작성하려면 로그인이 필요합니다' },
},
{
handler: 'navigate',
params: {
path: '/login',
query: { redirect: '/board/{{route.slug}}/write' },
},
},
],
},
},
},
],
children: [
{ type: 'basic', name: 'Icon', props: { name: 'pencil', size: 'sm' } },
{ type: 'basic', name: 'Span', text: '글쓰기' },
],
},
{
comment: '빈 페이지 (page > 1 + 데이터 0건)',
type: 'basic',
name: 'Div',
if: '{{posts?.data?.board?.slug && query.page && Number(query.page) > 1 && posts?.data?.data?.length === 0}}',
props: {
className: 'flex flex-col items-center justify-center py-16 text-center bg-gray-100/70 dark:bg-gray-800/50 rounded-xl',
'data-testid': 'empty-page',
},
children: [
{ type: 'basic', name: 'P', text: '해당 페이지에 게시글이 없습니다' },
{
type: 'basic',
name: 'Button',
text: '첫 페이지로 이동',
props: { 'data-testid': 'go-first-btn' },
actions: [
{
type: 'click',
handler: 'navigate',
params: { path: '/board/{{route.slug}}' },
},
],
},
],
},
{
comment: '게시글 없음 (검색/카테고리 아닐 때)',
type: 'basic',
name: 'Div',
if: "{{posts?.data?.board?.slug && !query.search && !query.category && (!query.page || Number(query.page) <= 1) && posts?.data?.pagination?.total === 0}}",
props: {
className: 'flex flex-col items-center justify-center py-16 text-center bg-gray-100/70 dark:bg-gray-800/50 rounded-xl',
'data-testid': 'empty-no-posts',
},
children: [
{ type: 'basic', name: 'P', text: '등록된 게시글이 없습니다' },
],
},
{
comment: '검색 결과 없음',
type: 'basic',
name: 'Div',
if: '{{posts?.data?.board?.slug && (query.search || query.category) && posts?.data?.pagination?.total === 0}}',
props: {
className: 'flex flex-col items-center justify-center py-16 text-center bg-gray-100/70 dark:bg-gray-800/50 rounded-xl',
'data-testid': 'empty-search',
},
children: [
{ type: 'basic', name: 'P', text: '검색 결과가 없습니다' },
{
type: 'basic',
name: 'Button',
text: '검색 초기화',
props: { 'data-testid': 'clear-search-btn' },
actions: [
{
type: 'click',
handler: 'navigate',
params: { path: '/board/{{route.slug}}' },
},
],
},
],
},
{
comment: '페이지네이션',
type: 'composite',
name: 'Pagination',
props: {
currentPage: '{{posts?.data?.pagination?.current_page ?? 1}}',
totalPages: '{{posts?.data?.pagination?.last_page ?? 1}}',
},
},
],
},
],
};
// ============================================================
// 테스트 데이터
// ============================================================
/** 게시글이 있는 기본 응답 */
const boardWithPostsResponse = {
data: {
board: {
id: 1,
slug: 'test-board',
name: '테스트 게시판',
type: 'basic',
categories: [],
},
data: [
{ id: 1, title: '첫 번째 글' },
{ id: 2, title: '두 번째 글' },
],
pagination: { current_page: 1, last_page: 2, total: 15 },
permissions: { posts_write: true },
},
};
/** 게시글이 없는 응답 (total=0) */
const emptyBoardResponse = {
data: {
board: {
id: 1,
slug: 'test-board',
name: '테스트 게시판',
type: 'basic',
categories: [],
},
data: [],
pagination: { current_page: 1, last_page: 1, total: 0 },
permissions: { posts_write: true },
},
};
/** page > 1이면서 데이터 0건인 응답 */
const emptyPageResponse = {
data: {
board: {
id: 1,
slug: 'test-board',
name: '테스트 게시판',
type: 'basic',
categories: [],
},
data: [],
pagination: { current_page: 5, last_page: 3, total: 25 },
permissions: { posts_write: true },
},
};
/** 글쓰기 권한 없는 응답 (로그인 상태) */
const boardNoWritePermResponse = {
data: {
board: {
id: 1,
slug: 'test-board',
name: '테스트 게시판',
type: 'basic',
categories: [],
},
data: [{ id: 1, title: '글1' }],
pagination: { current_page: 1, last_page: 1, total: 1 },
permissions: { posts_write: false },
},
};
// ============================================================
// 테스트
// ============================================================
describe('게시판 목록 - 빈 상태/로딩/오류/글쓰기 버튼', () => {
let registry: ComponentRegistry;
beforeEach(() => {
registry = setupTestRegistry();
});
describe('로딩 스켈레톤 표시 조건', () => {
it('데이터 로드 전 + 오류 없음: 로딩 스켈레톤 표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
initialData: {},
initGlobal: { hasError: false },
});
await testUtils.render();
expect(screen.queryByTestId('loading-skeleton')).toBeInTheDocument();
expect(screen.queryByTestId('error-page')).not.toBeInTheDocument();
expect(screen.queryByTestId('board-title')).not.toBeInTheDocument();
testUtils.cleanup();
});
it('데이터 로드 완료: 로딩 스켈레톤 미표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
initialData: { posts: boardWithPostsResponse },
});
await testUtils.render();
expect(screen.queryByTestId('loading-skeleton')).not.toBeInTheDocument();
expect(screen.queryByTestId('board-title')).toBeInTheDocument();
testUtils.cleanup();
});
});
describe('오류 페이지 표시 조건', () => {
it('_global.hasError가 true: 오류 페이지 표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
initialData: {},
initGlobal: { hasError: true },
});
await testUtils.render();
expect(screen.queryByTestId('error-page')).toBeInTheDocument();
expect(screen.queryByTestId('error-title')).toBeInTheDocument();
expect(screen.queryByTestId('reload-btn')).toBeInTheDocument();
expect(screen.queryByTestId('loading-skeleton')).not.toBeInTheDocument();
testUtils.cleanup();
});
});
describe('빈 상태 3종 조건부 렌더링', () => {
it('게시글 없음 (검색/카테고리 없이 total=0): empty-no-posts 표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
expect(screen.queryByTestId('empty-no-posts')).toBeInTheDocument();
expect(screen.queryByTestId('empty-search')).not.toBeInTheDocument();
expect(screen.queryByTestId('empty-page')).not.toBeInTheDocument();
testUtils.cleanup();
});
it('검색 결과 없음 (query.search 있고 total=0): empty-search 표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
queryParams: { search: '존재하지않는검색어' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
expect(screen.queryByTestId('empty-search')).toBeInTheDocument();
expect(screen.queryByTestId('empty-no-posts')).not.toBeInTheDocument();
expect(screen.queryByTestId('clear-search-btn')).toBeInTheDocument();
testUtils.cleanup();
});
it('카테고리 필터 결과 없음 (query.category 있고 total=0): empty-search 표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
queryParams: { category: '존재하지않는카테고리' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
expect(screen.queryByTestId('empty-search')).toBeInTheDocument();
expect(screen.queryByTestId('empty-no-posts')).not.toBeInTheDocument();
testUtils.cleanup();
});
it('빈 페이지 (page > 1 + data 0건): empty-page 표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
queryParams: { page: '5' },
initialData: { posts: emptyPageResponse },
});
await testUtils.render();
expect(screen.queryByTestId('empty-page')).toBeInTheDocument();
expect(screen.queryByTestId('go-first-btn')).toBeInTheDocument();
expect(screen.queryByTestId('empty-no-posts')).not.toBeInTheDocument();
testUtils.cleanup();
});
it('게시글이 있을 때 빈 상태 모두 미표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
initialData: { posts: boardWithPostsResponse },
});
await testUtils.render();
expect(screen.queryByTestId('empty-no-posts')).not.toBeInTheDocument();
expect(screen.queryByTestId('empty-search')).not.toBeInTheDocument();
expect(screen.queryByTestId('empty-page')).not.toBeInTheDocument();
testUtils.cleanup();
});
});
describe('빈 상태 배경 스타일 일관성', () => {
it('모든 빈 상태 컴포넌트가 동일한 배경 클래스를 가진다', () => {
const expectedBgClass = 'bg-gray-100/70 dark:bg-gray-800/50 rounded-xl';
const mainContainer = boardIndexLayoutJson.components[2];
const children = mainContainer.children;
// 빈 페이지, 게시글 없음, 검색 결과 없음 3개 컴포넌트 확인
const emptyPageComp = children.find(
(c: any) => c.props?.['data-testid'] === 'empty-page'
);
const emptyNoPostsComp = children.find(
(c: any) => c.props?.['data-testid'] === 'empty-no-posts'
);
const emptySearchComp = children.find(
(c: any) => c.props?.['data-testid'] === 'empty-search'
);
expect(emptyPageComp?.props?.className).toContain(expectedBgClass);
expect(emptyNoPostsComp?.props?.className).toContain(expectedBgClass);
expect(emptySearchComp?.props?.className).toContain(expectedBgClass);
});
});
describe('글쓰기 버튼 switch 핸들러', () => {
it('글쓰기 권한 있을 때 switch value가 "allowed"', () => {
// 정적 구조 검증: switch value 표현식 확인
const mainContainer = boardIndexLayoutJson.components[2];
const writeBtn = mainContainer.children.find(
(c: any) => c.props?.['data-testid'] === 'write-btn'
);
expect(writeBtn).toBeDefined();
expect(writeBtn.actions[0].handler).toBe('switch');
expect(writeBtn.actions[0].cases).toHaveProperty('allowed');
expect(writeBtn.actions[0].cases).toHaveProperty('no_permission');
expect(writeBtn.actions[0].cases).toHaveProperty('not_logged_in');
});
it('allowed 케이스: navigate 핸들러로 글쓰기 페이지 이동', () => {
const mainContainer = boardIndexLayoutJson.components[2];
const writeBtn = mainContainer.children.find(
(c: any) => c.props?.['data-testid'] === 'write-btn'
);
const allowedCase = writeBtn.actions[0].cases.allowed;
expect(allowedCase.handler).toBe('navigate');
expect(allowedCase.params.path).toBe('/board/{{route.slug}}/write');
});
it('no_permission 케이스: toast warning 표시', () => {
const mainContainer = boardIndexLayoutJson.components[2];
const writeBtn = mainContainer.children.find(
(c: any) => c.props?.['data-testid'] === 'write-btn'
);
const noPermCase = writeBtn.actions[0].cases.no_permission;
expect(noPermCase.handler).toBe('toast');
expect(noPermCase.params.type).toBe('warning');
});
it('not_logged_in 케이스: sequence로 toast + navigate (로그인 페이지)', () => {
const mainContainer = boardIndexLayoutJson.components[2];
const writeBtn = mainContainer.children.find(
(c: any) => c.props?.['data-testid'] === 'write-btn'
);
const notLoggedInCase = writeBtn.actions[0].cases.not_logged_in;
expect(notLoggedInCase.handler).toBe('sequence');
expect(notLoggedInCase.actions).toHaveLength(2);
expect(notLoggedInCase.actions[0].handler).toBe('toast');
expect(notLoggedInCase.actions[1].handler).toBe('navigate');
expect(notLoggedInCase.actions[1].params.path).toBe('/login');
expect(notLoggedInCase.actions[1].params.query.redirect).toBe(
'/board/{{route.slug}}/write'
);
});
it('글쓰기 버튼에 pencil 아이콘과 텍스트가 있다', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
initialData: { posts: boardWithPostsResponse },
});
await testUtils.render();
const writeBtn = screen.queryByTestId('write-btn');
expect(writeBtn).toBeInTheDocument();
testUtils.cleanup();
});
});
describe('페이지네이션 항상 표시', () => {
it('게시글이 있을 때 페이지네이션 표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
initialData: { posts: boardWithPostsResponse },
});
await testUtils.render();
expect(screen.queryByTestId('pagination')).toBeInTheDocument();
testUtils.cleanup();
});
it('게시글이 없을 때도 페이지네이션 표시', async () => {
const testUtils = createLayoutTest(boardIndexLayoutJson, {
componentRegistry: registry,
routeParams: { slug: 'test-board' },
initialData: { posts: emptyBoardResponse },
});
await testUtils.render();
expect(screen.queryByTestId('pagination')).toBeInTheDocument();
testUtils.cleanup();
});
});
});
@@ -0,0 +1,629 @@
/**
* 게시판 유형 관리 모달 레이아웃 JSON 구조 검증 테스트
*
* @description
* - Partial 모달: 게시판 유형 CRUD (목록 테이블 + 추가/수정 폼)
* - 상태 관리: _global.boardTypesList, btFormMode, btFormSlug, btFormName
* - API: GET/POST/PUT/DELETE board-types 엔드포인트
* - 핸들러: sequence, apiCall, setState, refetchDataSource, toast
*/
import { describe, it, expect } from 'vitest';
import modal from '../../../layouts/admin/partials/admin_board_form/_board_type_manage_modal.json';
/**
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
*/
function findById(node: any, id: string): any | null {
if (!node) return null;
if (node.id === id) return node;
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
const found = findById(child, id);
if (found) return found;
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
const found = findById(child as any, id);
if (found) return found;
}
}
}
}
return null;
}
/**
* JSON 트리에서 특정 name을 가진 컴포넌트를 모두 찾습니다.
*/
function findByName(node: any, name: string): any[] {
const results: any[] = [];
if (!node) return results;
if (node.name === name) {
results.push(node);
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findByName(child, name));
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
results.push(...findByName(child as any, name));
}
}
}
}
return results;
}
/**
* JSON 트리에서 특정 handler를 가진 액션을 모두 재귀적으로 찾습니다.
*/
function findActions(node: any, handlerName: string): any[] {
const results: any[] = [];
if (!node) return results;
if (node.actions && Array.isArray(node.actions)) {
for (const action of node.actions) {
if (action.handler === handlerName) {
results.push(action);
}
if (action.actions && Array.isArray(action.actions)) {
for (const subAction of action.actions) {
if (subAction.handler === handlerName) {
results.push(subAction);
}
if (subAction.onSuccess && Array.isArray(subAction.onSuccess)) {
for (const cb of subAction.onSuccess) {
if (cb.handler === handlerName) results.push(cb);
if (cb.onSuccess && Array.isArray(cb.onSuccess)) {
for (const nested of cb.onSuccess) {
if (nested.handler === handlerName) results.push(nested);
}
}
}
}
if (subAction.onError && Array.isArray(subAction.onError)) {
for (const cb of subAction.onError) {
if (cb.handler === handlerName) results.push(cb);
}
}
}
}
}
}
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findActions(child, handlerName));
}
}
return results;
}
describe('게시판 유형 관리 모달 레이아웃', () => {
describe('메타 정보', () => {
it('Partial 메타 정보가 올바르다', () => {
expect(modal.meta).toBeDefined();
expect(modal.meta.is_partial).toBe(true);
});
it('모달 기본 속성이 올바르다', () => {
expect(modal.id).toBe('board_type_manage_modal');
expect(modal.type).toBe('composite');
expect(modal.name).toBe('Modal');
expect(modal.props.width).toBe('640px');
});
it('모달 제목이 다국어 키로 설정되어 있다', () => {
expect(modal.props.title).toBe('$t:sirsoft-board.admin.board_types.modal_title');
});
it('슬러그 사용법 안내 텍스트가 모달 상단에 있다', () => {
const guide = findById(modal, 'bt_usage_guide');
expect(guide).not.toBeNull();
expect(guide.name).toBe('Div');
// 아이콘 + 텍스트 children 구조
const icon = findById(guide, 'bt_usage_guide_icon');
expect(icon).not.toBeNull();
expect(icon.name).toBe('Icon');
expect(icon.props.name).toBe('fa-solid fa-circle-info');
const text = findById(guide, 'bt_usage_guide_text');
expect(text).not.toBeNull();
expect(text.text).toBe('$t:sirsoft-board.admin.board_types.usage_guide');
});
});
describe('테이블 구조', () => {
it('테이블 헤더에 3개 컬럼이 있다 (slug, name, actions)', () => {
const theadRow = findById(modal, 'bt_thead_row');
expect(theadRow).not.toBeNull();
expect(theadRow.children).toHaveLength(3);
});
it('각 헤더 컬럼이 올바른 다국어 키를 사용한다', () => {
const expectedColumns = [
{ id: 'bt_th_slug', text: '$t:sirsoft-board.admin.board_types.slug' },
{ id: 'bt_th_name', text: '$t:sirsoft-board.admin.board_types.name' },
{ id: 'bt_th_actions', text: '$t:sirsoft-board.admin.board_types.actions' },
];
for (const col of expectedColumns) {
const th = findById(modal, col.id);
expect(th).not.toBeNull();
expect(th.text).toBe(col.text);
}
});
it('빈 상태 행이 존재하고 조건부 렌더링된다', () => {
const emptyRow = findById(modal, 'bt_empty_row');
expect(emptyRow).not.toBeNull();
expect(emptyRow.if).toBe('{{!_global.boardTypesList || _global.boardTypesList.length === 0}}');
const emptyCell = findById(modal, 'bt_empty_cell');
expect(emptyCell.props.colSpan).toBe(3);
});
it('데이터 행에 iteration이 올바르게 설정되어 있다', () => {
const dataRow = findById(modal, 'bt_row');
expect(dataRow).not.toBeNull();
expect(dataRow.if).toBe('{{_global.boardTypesList && _global.boardTypesList.length > 0}}');
expect(dataRow.iteration).toEqual({
source: '{{_global.boardTypesList ?? []}}',
item_var: 'btItem',
index_var: 'btIdx',
});
});
it('데이터 행에 3개 셀이 있다 (slug, name, actions)', () => {
const dataRow = findById(modal, 'bt_row');
expect(dataRow.children).toHaveLength(3);
});
});
describe('테이블 행 데이터 바인딩', () => {
it('slug 셀이 btItem.slug을 표시한다', () => {
const slugCell = findById(modal, 'bt_td_slug');
expect(slugCell.text).toBe('{{btItem.slug}}');
});
it('name 셀이 다국어 fallback 체인으로 표시한다', () => {
const nameCell = findById(modal, 'bt_td_name');
expect(nameCell.text).toContain('btItem.name');
expect(nameCell.text).toContain('_global.locale');
});
});
describe('액션 버튼', () => {
it('수정 버튼이 전역 상태를 폼에 로드한다', () => {
const editBtn = findById(modal, 'bt_edit_btn');
expect(editBtn).not.toBeNull();
const editAction = editBtn.actions[0];
expect(editAction.handler).toBe('setState');
expect(editAction.params.target).toBe('global');
expect(editAction.params.btFormMode).toBe('edit');
expect(editAction.params.btFormSlug).toBe('{{btItem.slug}}');
// btFormName은 객체로 설정
expect(editAction.params.btFormName).toContain('btItem.name');
});
it('삭제 버튼이 sequence로 API 호출 후 목록 갱신한다', () => {
const deleteBtn = findById(modal, 'bt_delete_btn');
expect(deleteBtn).not.toBeNull();
const clickAction = deleteBtn.actions[0];
expect(clickAction.handler).toBe('sequence');
expect(clickAction.actions).toHaveLength(2);
expect(clickAction.actions[0].handler).toBe('setState');
expect(clickAction.actions[0].params.btDeletingId).toBe('{{btItem.id}}');
const apiCall = clickAction.actions[1];
expect(apiCall.handler).toBe('apiCall');
expect(apiCall.params.method).toBe('DELETE');
expect(apiCall.target).toContain('/api/modules/sirsoft-board/admin/board-types/');
});
it('삭제 API 호출에 confirm 확인 메시지가 설정되어 있다', () => {
const deleteBtn = findById(modal, 'bt_delete_btn');
const apiCall = deleteBtn.actions[0].actions[1];
expect(apiCall.confirm).toBe('$t:sirsoft-board.admin.board_types.delete_confirm');
});
it('삭제 아이콘과 스피너가 조건부 렌더링된다', () => {
const deleteIcon = findById(modal, 'bt_delete_icon');
const deleteSpinner = findById(modal, 'bt_delete_spinner');
expect(deleteIcon.if).toBe('{{_global.btDeletingId !== btItem.id}}');
expect(deleteSpinner.if).toBe('{{_global.btDeletingId === btItem.id}}');
});
});
describe('폼 섹션', () => {
it('폼에 2개 필드가 있다 (slug, name)', () => {
const formFields = findById(modal, 'bt_form_fields');
expect(formFields).not.toBeNull();
expect(formFields.children).toHaveLength(2);
});
it('slug Input이 올바른 전역 상태 바인딩을 사용한다', () => {
const slugInput = findById(modal, 'bt_input_slug');
expect(slugInput).not.toBeNull();
expect(slugInput.props.value).toBe("{{_global.btFormSlug ?? ''}}");
});
it('name 필드가 MultilingualInput 컴포넌트를 사용한다', () => {
const nameInput = findById(modal, 'bt_input_name');
expect(nameInput).not.toBeNull();
expect(nameInput.name).toBe('MultilingualInput');
expect(nameInput.type).toBe('composite');
expect(nameInput.props.layout).toBe('tabs');
expect(nameInput.props.required).toBe(true);
});
it('MultilingualInput이 btFormName 상태에 바인딩된다', () => {
const nameInput = findById(modal, 'bt_input_name');
expect(nameInput.props.value).toContain('_global.btFormName');
const onChangeAction = nameInput.actions[0];
expect(onChangeAction.event).toBe('onChange');
expect(onChangeAction.handler).toBe('setState');
expect(onChangeAction.params.target).toBe('global');
expect(onChangeAction.params.btFormName).toContain('$args[0]?.target?.value');
});
it('slug Input의 setState 핸들러가 btFormSlug를 설정한다', () => {
const slugInput = findById(modal, 'bt_input_slug');
const setStateAction = slugInput.actions[0];
expect(setStateAction.handler).toBe('setState');
expect(setStateAction.params.target).toBe('global');
expect(setStateAction.params.btFormSlug).toBe('{{$event.target.value}}');
});
it('slug 필드가 수정 모드에서 비활성화된다', () => {
const slugInput = findById(modal, 'bt_input_slug');
expect(slugInput.props.disabled).toBe("{{_computed.isReadOnly || _global.btFormMode === 'edit'}}");
});
});
describe('폼 헤더 및 모드 전환', () => {
it('폼 제목이 add/edit 모드에 따라 변경된다', () => {
const title = findById(modal, 'bt_form_title');
expect(title.text).toContain('btFormMode');
expect(title.text).toContain('edit_title');
expect(title.text).toContain('add_title');
});
it('취소 버튼이 수정 모드에서만 표시된다', () => {
const cancelBtn = findById(modal, 'bt_form_cancel_btn');
expect(cancelBtn.if).toBe("{{_global.btFormMode === 'edit'}}");
});
it('취소 버튼에 다크모드 포함 스타일이 적용된다', () => {
const cancelBtn = findById(modal, 'bt_form_cancel_btn');
expect(cancelBtn.props.className).toContain('text-gray-600');
expect(cancelBtn.props.className).toContain('dark:text-gray-300');
expect(cancelBtn.props.className).toContain('dark:bg-gray-700');
expect(cancelBtn.props.className).toContain('text-xs');
});
it('취소 버튼 클릭 시 폼을 초기화한다', () => {
const cancelBtn = findById(modal, 'bt_form_cancel_btn');
const action = cancelBtn.actions[0];
expect(action.handler).toBe('setState');
expect(action.params.btFormMode).toBe('add');
expect(action.params.btEditingItem).toBeNull();
expect(action.params.btFormSlug).toBe('');
expect(action.params.btFormName).toEqual({ ko: '', en: '' });
});
});
describe('제출 버튼', () => {
it('제출 버튼이 필수 필드 검증으로 비활성화 조건을 가진다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
expect(submitBtn.props.disabled).toContain('_global.btSaving');
expect(submitBtn.props.disabled).toContain('_global.btFormSlug');
expect(submitBtn.props.disabled).toContain('_global.btFormName');
});
it('제출 시 모드에 따라 POST 또는 PUT을 호출한다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
const clickAction = submitBtn.actions[0];
expect(clickAction.handler).toBe('sequence');
const apiCall = clickAction.actions[1];
expect(apiCall.handler).toBe('apiCall');
expect(apiCall.params.method).toContain('btFormMode');
expect(apiCall.params.method).toContain('PUT');
expect(apiCall.params.method).toContain('POST');
});
it('제출 요청 body에 slug과 name 필드가 포함된다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
const apiCall = submitBtn.actions[0].actions[1];
const body = apiCall.params.body;
expect(body.slug).toBe('{{_global.btFormSlug}}');
expect(body.name).toContain('_global.btFormName');
});
it('제출 성공 시 폼을 초기화하고 목록을 갱신한다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
const apiCall = submitBtn.actions[0].actions[1];
const onSuccess = apiCall.onSuccess;
const resetAction = onSuccess[0];
expect(resetAction.handler).toBe('setState');
expect(resetAction.params.btSaving).toBe(false);
expect(resetAction.params.btFormMode).toBe('add');
expect(resetAction.params.btFormSlug).toBe('');
expect(resetAction.params.btFormName).toEqual({ ko: '', en: '' });
const refetchCall = onSuccess[1];
expect(refetchCall.handler).toBe('apiCall');
expect(refetchCall.target).toBe('/api/modules/sirsoft-board/admin/board-types');
expect(refetchCall.params.method).toBe('GET');
});
it('제출 스피너가 저장 중에만 표시된다', () => {
const spinner = findById(modal, 'bt_submit_spinner');
expect(spinner.if).toBe('{{_global.btSaving}}');
});
it('제출 버튼 텍스트가 모드와 저장 상태에 따라 변경된다', () => {
const text = findById(modal, 'bt_submit_text');
expect(text.text).toContain('btFormMode');
expect(text.text).toContain('btSaving');
});
});
describe('API 엔드포인트', () => {
it('모든 API 호출이 올바른 base URL을 사용한다', () => {
const allApiCalls = findActions(modal, 'apiCall');
const basePath = '/api/modules/sirsoft-board/admin/board-types';
for (const apiCall of allApiCalls) {
expect(apiCall.target).toContain(basePath);
}
});
it('모든 API 호출에 auth_required가 설정되어 있다', () => {
const allApiCalls = findActions(modal, 'apiCall');
for (const apiCall of allApiCalls) {
expect(apiCall.auth_required).toBe(true);
}
});
});
describe('데이터소스 갱신', () => {
it('성공 콜백에서 boards와 board_types 데이터소스를 갱신한다', () => {
const refetchActions = findActions(modal, 'refetchDataSource');
const dataSourceIds = refetchActions.map((a: any) => a.params.dataSourceId);
expect(dataSourceIds).toContain('boards');
expect(dataSourceIds).toContain('board_types');
});
});
describe('에러 처리', () => {
it('제출 API 호출의 onError에서 btSaving을 false로 설정한다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
const seq = submitBtn.actions[0];
const apiCall = seq.actions.find((a: any) => a.handler === 'apiCall');
expect(apiCall).not.toBeNull();
expect(apiCall.onError).toBeDefined();
const resetState = apiCall.onError.find((a: any) => a.handler === 'setState');
expect(resetState.params.btSaving).toBe(false);
});
it('삭제 API 호출의 onError에서 btDeletingId를 null로 설정한다', () => {
const deleteBtn = findById(modal, 'bt_delete_btn');
const seq = deleteBtn.actions[0];
const apiCall = seq.actions.find((a: any) => a.handler === 'apiCall');
expect(apiCall).not.toBeNull();
expect(apiCall.onError).toBeDefined();
const resetState = apiCall.onError.find((a: any) => a.handler === 'setState');
expect(resetState.params.btDeletingId).toBeNull();
// 삭제는 btSaving을 사용하지 않음 (저장 버튼 로딩과 분리)
expect(resetState.params.btSaving).toBeUndefined();
});
it('에러 시 toast로 오류 메시지를 표시한다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
const apiCall = submitBtn.actions[0].actions[1];
const errorToast = apiCall.onError.find((a: any) => a.handler === 'toast');
expect(errorToast.params.type).toBe('error');
expect(errorToast.params.message).toBe('{{error.errors ? Object.values(error.errors).flat()[0] : error.message}}');
});
});
describe('모달 닫기', () => {
it('닫기 버튼이 존재하고 sequence로 폼 초기화 후 모달을 닫는다', () => {
const closeBtn = findById(modal, 'bt_modal_close_btn');
expect(closeBtn).not.toBeNull();
const seq = closeBtn.actions[0];
expect(seq.handler).toBe('sequence');
expect(seq.actions).toHaveLength(2);
expect(seq.actions[0].handler).toBe('setState');
expect(seq.actions[0].params.btFormMode).toBe('add');
expect(seq.actions[0].params.btFormName).toEqual({ ko: '', en: '' });
expect(seq.actions[1].handler).toBe('closeModal');
});
});
describe('다크 모드', () => {
it('테이블 본문에 다크 모드 클래스가 있다', () => {
const tbody = findById(modal, 'bt_tbody');
expect(tbody.props.className).toContain('dark:bg-gray-800');
});
it('헤더 셀에 다크 모드 텍스트 색상이 있다', () => {
const thSlug = findById(modal, 'bt_th_slug');
expect(thSlug.props.className).toContain('dark:text-gray-400');
});
it('데이터 행에 다크 모드 호버 효과가 있다', () => {
const row = findById(modal, 'bt_row');
expect(row.props.className).toContain('dark:hover:bg-gray-700');
});
it('폼 구분선에 다크 모드 색상이 있다', () => {
const formSection = findById(modal, 'bt_form_section');
expect(formSection.props.className).toContain('dark:border-gray-700');
});
});
describe('모달 UI/UX 개선', () => {
it('수정 중인 행에 classMap으로 하이라이트 배경이 적용된다', () => {
const row = findById(modal, 'bt_row');
expect(row.classMap).toBeDefined();
expect(row.classMap.key).toContain('btEditingItem');
expect(row.classMap.variants.true).toContain('bg-blue-50');
expect(row.classMap.variants.true).toContain('dark:bg-blue-900');
});
it('수정 모드 진입 시 폼 영역에 ring 효과가 적용된다', () => {
const formSection = findById(modal, 'bt_form_section');
expect(formSection.classMap).toBeDefined();
expect(formSection.classMap.key).toBe('{{_global.btFormMode}}');
expect(formSection.classMap.variants.edit).toContain('ring-2');
expect(formSection.classMap.variants.edit).toContain('ring-blue-300');
expect(formSection.classMap.variants.add).toBe('');
});
it('slug 필드에 허용 형식 힌트가 있다 (에러 없을 때만 표시)', () => {
const hint = findById(modal, 'bt_slug_hint');
expect(hint).not.toBeNull();
expect(hint.name).toBe('P');
expect(hint.text).toBe('$t:sirsoft-board.admin.board_types.slug_hint');
expect(hint.if).toContain("btFormMode !== 'edit'");
expect(hint.if).toContain('btFormErrors');
});
it('Modal에 width prop이 설정되어 있다', () => {
expect(modal.props.width).toBe('640px');
});
});
describe('폼 유효성 에러 표시', () => {
it('slug Input에 에러 시 빨간 테두리를 위한 classMap이 있다', () => {
const slugInput = findById(modal, 'bt_input_slug');
expect(slugInput.classMap).toBeDefined();
expect(slugInput.classMap.key).toContain('btFormErrors?.slug');
expect(slugInput.classMap.variants.error).toContain('border-red-500');
expect(slugInput.classMap.variants.error).toContain('dark:border-red-400');
expect(slugInput.classMap.variants.normal).toContain('border-gray-300');
});
it('slug 에러 메시지 요소가 존재하고 조건부 렌더링된다', () => {
const slugError = findById(modal, 'bt_slug_error');
expect(slugError).not.toBeNull();
expect(slugError.name).toBe('P');
expect(slugError.if).toContain('btFormErrors?.slug');
expect(slugError.text).toContain('btFormErrors?.slug?.[0]');
expect(slugError.props.className).toContain('text-red-500');
expect(slugError.props.className).toContain('dark:text-red-400');
});
it('name 에러 메시지 요소가 존재하고 조건부 렌더링된다', () => {
const nameError = findById(modal, 'bt_name_error');
expect(nameError).not.toBeNull();
expect(nameError.name).toBe('P');
expect(nameError.if).toContain("btFormErrors?.['name.ko']");
expect(nameError.text).toContain("btFormErrors?.['name.ko']?.[0]");
expect(nameError.props.className).toContain('text-red-500');
});
it('MultilingualInput에 error prop이 전달된다', () => {
const nameInput = findById(modal, 'bt_input_name');
expect(nameInput.props.error).toBeDefined();
expect(nameInput.props.error).toContain("btFormErrors?.['name.ko']");
});
it('slug Input 변경 시 slug 에러를 클리어한다', () => {
const slugInput = findById(modal, 'bt_input_slug');
const inputAction = slugInput.actions[0];
expect(inputAction.params.btFormErrors).toBeDefined();
expect(inputAction.params.btFormErrors).toContain('delete e.slug');
});
it('name Input 변경 시 name 에러를 클리어한다', () => {
const nameInput = findById(modal, 'bt_input_name');
const onChangeAction = nameInput.actions[0];
expect(onChangeAction.params.btFormErrors).toBeDefined();
expect(onChangeAction.params.btFormErrors).toContain("delete e['name.ko']");
});
it('제출 시 에러 상태를 초기화한다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
const seq = submitBtn.actions[0];
const initState = seq.actions[0];
expect(initState.params.btFormErrors).toBeNull();
});
it('제출 onError에서 btFormErrors에 에러를 저장한다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
const apiCall = submitBtn.actions[0].actions[1];
const errorState = apiCall.onError.find((a: any) => a.handler === 'setState');
expect(errorState.params.btFormErrors).toBe('{{error.errors ?? null}}');
});
it('취소 버튼 클릭 시 에러를 초기화한다', () => {
const cancelBtn = findById(modal, 'bt_form_cancel_btn');
const action = cancelBtn.actions[0];
expect(action.params.btFormErrors).toBeNull();
});
it('모달 닫기 시 에러를 초기화한다', () => {
const closeBtn = findById(modal, 'bt_modal_close_btn');
const seq = closeBtn.actions[0];
const resetAction = seq.actions[0];
expect(resetAction.params.btFormErrors).toBeNull();
});
it('수정 버튼 클릭 시 에러를 초기화한다', () => {
const editBtn = findById(modal, 'bt_edit_btn');
const action = editBtn.actions[0];
expect(action.params.btFormErrors).toBeNull();
});
it('제출 성공 시 에러를 초기화한다', () => {
const submitBtn = findById(modal, 'bt_submit_btn');
const apiCall = submitBtn.actions[0].actions[1];
const successState = apiCall.onSuccess[0];
expect(successState.params.btFormErrors).toBeNull();
});
});
describe('Button type 속성', () => {
it('모든 Button에 type="button"이 명시되어 있다 (submit 방지)', () => {
const buttons = findByName(modal, 'Button');
for (const btn of buttons) {
expect(btn.props.type).toBe('button');
}
});
});
});
@@ -0,0 +1,319 @@
/**
* 게시판 사용자 레이아웃 abilities 키 마이그레이션 검증 테스트
*
* @description
* - permissions -> abilities 키 마이그레이션 완료 검증
* - user_permissions -> user_abilities 키 마이그레이션 완료 검증
* - 모든 board 관련 레이아웃 JSON에서 구 키(permissions, user_permissions) 잔존 여부 확인
*/
import { describe, it, expect } from 'vitest';
// === Board Index 관련 ===
import writeButton from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/index/_write_button.json';
// === Board Show 관련 ===
import commentInput from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/show/_comment_input.json';
import commentSection from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/show/_comment_section.json';
import commentItem from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/show/_comment_item.json';
import postAttachments from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/show/_post_attachments.json';
import basicShow from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/types/basic/show.json';
// === Board Form 관련 ===
import postForm from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/form/_post_form.json';
import typeRenderer from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/form/_type_renderer.json';
import passwordVerifyModal from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/form/_password_verify_modal.json';
// === Shop QnA (게시판 abilities 사용) ===
import tabQna from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_tab_qna.json';
// === Board Index 타입별 ===
import galleryIndex from '../../../../../../../templates/_bundled/sirsoft-basic/layouts/partials/board/types/gallery/index.json';
/**
* JSON 노드를 재귀적으로 순회하며 모든 문자열 값에서 {{...}} 표현식을 추출합니다.
*
* @param node JSON 노드 (객체, 배열, 또는 프리미티브)
* @param expressions 수집된 표현식 배열 (참조로 전달)
* @param path 현재 노드 경로 (디버깅용)
*/
function findExpressionsInJson(
node: unknown,
expressions: Array<{ expression: string; path: string }> = [],
path: string = '$'
): Array<{ expression: string; path: string }> {
if (node === null || node === undefined) return expressions;
if (typeof node === 'string') {
// {{...}} 패턴 매칭 (중첩 가능하므로 전체 문자열에서 모두 추출)
const matches = node.match(/\{\{[^}]*\}\}/g);
if (matches) {
for (const match of matches) {
expressions.push({ expression: match, path });
}
}
return expressions;
}
if (Array.isArray(node)) {
node.forEach((item, index) => {
findExpressionsInJson(item, expressions, `${path}[${index}]`);
});
return expressions;
}
if (typeof node === 'object') {
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
findExpressionsInJson(value, expressions, `${path}.${key}`);
}
}
return expressions;
}
/**
* 주어진 표현식 목록에서 특정 패턴을 포함하는 표현식을 필터링합니다.
*/
function findExpressionsContaining(
expressions: Array<{ expression: string; path: string }>,
pattern: string | RegExp
): Array<{ expression: string; path: string }> {
return expressions.filter(({ expression }) => {
if (typeof pattern === 'string') {
return expression.includes(pattern);
}
return pattern.test(expression);
});
}
// 모든 레이아웃 파일 목록 (파일명과 JSON 데이터 매핑)
const allBoardLayouts: Array<{ name: string; json: unknown }> = [
{ name: 'index/_write_button.json', json: writeButton },
{ name: 'show/_comment_input.json', json: commentInput },
{ name: 'show/_comment_section.json', json: commentSection },
{ name: 'show/_comment_item.json', json: commentItem },
{ name: 'show/_post_attachments.json', json: postAttachments },
{ name: 'types/basic/show.json', json: basicShow },
{ name: 'form/_post_form.json', json: postForm },
{ name: 'form/_type_renderer.json', json: typeRenderer },
{ name: 'form/_password_verify_modal.json', json: passwordVerifyModal },
{ name: 'shop/detail/_tab_qna.json', json: tabQna },
{ name: 'types/gallery/index.json', json: galleryIndex },
];
// 폼 관련 레이아웃 (user_permissions -> user_abilities 확인 대상)
const formRelatedLayouts: Array<{ name: string; json: unknown }> = [
{ name: 'form/_post_form.json', json: postForm },
{ name: 'form/_type_renderer.json', json: typeRenderer },
{ name: 'form/_password_verify_modal.json', json: passwordVerifyModal },
];
describe('게시판 사용자 레이아웃 abilities 키 마이그레이션 검증', () => {
describe('permissions -> abilities 키 잔존 검사 (전체 레이아웃)', () => {
it.each(allBoardLayouts)(
'$name: permissions 키가 남아있지 않아야 합니다',
({ json }) => {
const expressions = findExpressionsInJson(json);
// "permissions" 단어를 포함하지만 "abilities"의 일부가 아닌 표현식 검색
// 예: "permissions?.can_write" (구 키) vs "abilities?.can_write" (신 키)
const oldKeyExpressions = findExpressionsContaining(
expressions,
/(?<!\w)permissions(?!\w)/
);
expect(
oldKeyExpressions,
`구 키 "permissions"가 다음 위치에 남아있습니다:\n${oldKeyExpressions.map((e) => ` - ${e.path}: ${e.expression}`).join('\n')}`
).toHaveLength(0);
}
);
});
describe('user_permissions -> user_abilities 키 잔존 검사 (폼 관련 레이아웃)', () => {
it.each(formRelatedLayouts)(
'$name: user_permissions 키가 남아있지 않아야 합니다',
({ json }) => {
const expressions = findExpressionsInJson(json);
const oldKeyExpressions = findExpressionsContaining(
expressions,
'user_permissions'
);
expect(
oldKeyExpressions,
`구 키 "user_permissions"가 다음 위치에 남아있습니다:\n${oldKeyExpressions.map((e) => ` - ${e.path}: ${e.expression}`).join('\n')}`
).toHaveLength(0);
}
);
});
describe('abilities 키 사용 확인 (양성 검증)', () => {
it('_write_button.json: posts?.data?.abilities?.can_write 표현식 포함', () => {
const expressions = findExpressionsInJson(writeButton);
const abilitiesExpressions = findExpressionsContaining(
expressions,
'abilities?.can_write'
);
expect(abilitiesExpressions.length).toBeGreaterThanOrEqual(1);
});
it('_write_button.json: disabled prop에 abilities?.can_write 체크 포함', () => {
const button = writeButton as Record<string, unknown>;
const props = button.props as Record<string, unknown>;
expect(props.disabled).toBeDefined();
expect(String(props.disabled)).toContain('abilities?.can_write');
});
it('_comment_input.json: post?.data?.abilities?.can_write_comments 표현식 포함', () => {
const expressions = findExpressionsInJson(commentInput);
const abilitiesExpressions = findExpressionsContaining(
expressions,
'abilities?.can_write_comments'
);
expect(abilitiesExpressions.length).toBeGreaterThanOrEqual(1);
});
it('_comment_section.json: abilities?.can_read_comments 및 abilities?.can_write_comments 표현식 포함', () => {
const expressions = findExpressionsInJson(commentSection);
const canReadComments = findExpressionsContaining(
expressions,
'abilities?.can_read_comments'
);
const canWriteComments = findExpressionsContaining(
expressions,
'abilities?.can_write_comments'
);
expect(canReadComments.length).toBeGreaterThanOrEqual(1);
expect(canWriteComments.length).toBeGreaterThanOrEqual(1);
});
it('_comment_item.json: abilities?.can_write_comments 및 abilities?.can_manage 표현식 포함', () => {
const expressions = findExpressionsInJson(commentItem);
const canWriteComments = findExpressionsContaining(
expressions,
'abilities?.can_write_comments'
);
const canManage = findExpressionsContaining(
expressions,
'abilities?.can_manage'
);
expect(canWriteComments.length).toBeGreaterThanOrEqual(1);
expect(canManage.length).toBeGreaterThanOrEqual(1);
});
it('types/basic/show.json: abilities?.can_write 및 abilities?.can_manage 표현식 포함', () => {
const expressions = findExpressionsInJson(basicShow);
const canWrite = findExpressionsContaining(
expressions,
'abilities?.can_write'
);
const canManage = findExpressionsContaining(
expressions,
'abilities?.can_manage'
);
expect(canWrite.length).toBeGreaterThanOrEqual(1);
expect(canManage.length).toBeGreaterThanOrEqual(1);
});
it('types/basic/show.json: abilities?.can_read_comments 표현식 포함', () => {
const expressions = findExpressionsInJson(basicShow);
const canReadComments = findExpressionsContaining(
expressions,
'abilities?.can_read_comments'
);
expect(canReadComments.length).toBeGreaterThanOrEqual(1);
});
it('form/_post_form.json: user_abilities?.can_manage 및 user_abilities?.can_upload 표현식 포함', () => {
const expressions = findExpressionsInJson(postForm);
const canManage = findExpressionsContaining(
expressions,
'user_abilities?.can_manage'
);
const canUpload = findExpressionsContaining(
expressions,
'user_abilities?.can_upload'
);
expect(canManage.length).toBeGreaterThanOrEqual(1);
expect(canUpload.length).toBeGreaterThanOrEqual(1);
});
it('form/_type_renderer.json: user_abilities?.can_manage 표현식 포함', () => {
const expressions = findExpressionsInJson(typeRenderer);
const canManage = findExpressionsContaining(
expressions,
'user_abilities?.can_manage'
);
expect(canManage.length).toBeGreaterThanOrEqual(1);
});
it('form/_password_verify_modal.json: user_abilities?.can_manage 표현식 포함', () => {
const expressions = findExpressionsInJson(passwordVerifyModal);
const canManage = findExpressionsContaining(
expressions,
'user_abilities?.can_manage'
);
expect(canManage.length).toBeGreaterThanOrEqual(1);
});
it('shop/detail/_tab_qna.json: abilities?.can_write 표현식 포함', () => {
const expressions = findExpressionsInJson(tabQna);
const canWrite = findExpressionsContaining(
expressions,
'abilities?.can_write'
);
expect(canWrite.length).toBeGreaterThanOrEqual(1);
});
});
describe('findExpressionsInJson 헬퍼 정합성 검증', () => {
it('중첩 JSON에서 모든 표현식을 추출합니다', () => {
const testNode = {
if: '{{a.permissions.x}}',
props: {
className: 'static-class',
disabled: '{{b.abilities.y}}',
},
children: [
{
text: '{{c.value}}',
},
],
};
const expressions = findExpressionsInJson(testNode);
expect(expressions).toHaveLength(3);
expect(expressions[0].expression).toBe('{{a.permissions.x}}');
expect(expressions[1].expression).toBe('{{b.abilities.y}}');
expect(expressions[2].expression).toBe('{{c.value}}');
});
it('표현식이 없는 정적 문자열은 무시합니다', () => {
const testNode = {
type: 'basic',
name: 'Div',
text: 'no expressions here',
};
const expressions = findExpressionsInJson(testNode);
expect(expressions).toHaveLength(0);
});
it('한 문자열에 여러 표현식이 있으면 모두 추출합니다', () => {
const testNode = {
text: '{{a.permissions}} and {{b.abilities}}',
};
const expressions = findExpressionsInJson(testNode);
expect(expressions).toHaveLength(2);
});
});
});
@@ -0,0 +1,126 @@
/**
* 게시판 신고현황 named_actions 검증 테스트
*
* @description
* - named_actions.searchBoardReports 정의 검증
* - Desktop/Mobile Enter keypress + 검색 버튼 click이 actionRef 참조하는지 검증
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import boardReports from '../../../layouts/admin/admin_board_reports_index.json';
/** JSON 전체에서 actionRef를 가진 액션을 찾는 유틸리티 */
function findActionRefs(obj: any, refName: string, results: any[] = []): any[] {
if (!obj) return results;
if (typeof obj === 'object') {
if (obj.actionRef === refName) {
results.push(obj);
}
for (const key of Object.keys(obj)) {
findActionRefs(obj[key], refName, results);
}
}
return results;
}
/** JSON 전체에서 특정 id를 가진 컴포넌트를 찾는 유틸리티 */
function findComponentById(obj: any, id: string): any | null {
if (!obj) return null;
if (typeof obj === 'object') {
if (obj.id === id) return obj;
for (const key of Object.keys(obj)) {
const found = findComponentById(obj[key], id);
if (found) return found;
}
}
return null;
}
describe('게시판 신고현황 named_actions 검증', () => {
it('레이아웃에 named_actions.searchBoardReports가 정의되어 있어야 함', () => {
const namedActions = (boardReports as any).named_actions;
expect(namedActions).toBeDefined();
expect(namedActions.searchBoardReports).toBeDefined();
});
it('searchBoardReports가 올바른 navigate 핸들러를 가져야 함', () => {
const action = (boardReports as any).named_actions.searchBoardReports;
expect(action.handler).toBe('navigate');
expect(action.params.path).toBe('/admin/boards/reports');
expect(action.params.query['filters[0][operator]']).toBe('like');
expect(action.params.query.status).toBeDefined();
expect(action.params.query.type).toBeDefined();
});
it('Desktop + Mobile Enter keypress가 actionRef로 searchBoardReports를 참조해야 함', () => {
const refs = findActionRefs(boardReports, 'searchBoardReports');
const enterRefs = refs.filter((r: any) => r.type === 'keypress' && r.key === 'Enter');
expect(enterRefs.length).toBe(2); // desktop + mobile
});
it('검색 버튼 click이 actionRef로 searchBoardReports를 참조해야 함', () => {
const refs = findActionRefs(boardReports, 'searchBoardReports');
const clickRef = refs.find((r: any) => r.type === 'click');
expect(clickRef).toBeDefined();
});
it('총 3개의 actionRef가 searchBoardReports를 참조해야 함', () => {
const refs = findActionRefs(boardReports, 'searchBoardReports');
expect(refs.length).toBe(3);
});
});
describe('모바일 Dropdown 일괄 처리 검증', () => {
it('mobile_bulk_action_dropdown의 items가 value 필드를 가져야 함 (key 금지)', () => {
const dropdown = findComponentById(boardReports, 'mobile_bulk_action_dropdown');
expect(dropdown).toBeDefined();
const items = dropdown.props.items as Array<{ value?: string; key?: string; label: string }>;
expect(items).toBeDefined();
expect(items.length).toBeGreaterThan(0);
for (const item of items) {
expect(item.key, `items에 "key" 필드 사용 금지 (value: ${item.key})`).toBeUndefined();
expect(item.value, `items의 "value" 필드가 정의되어야 함`).toBeDefined();
}
});
it('mobile_bulk_action_dropdown의 switch cases가 items의 value와 일치해야 함', () => {
const dropdown = findComponentById(boardReports, 'mobile_bulk_action_dropdown');
const items = dropdown.props.items as Array<{ value: string }>;
const switchAction = dropdown.actions?.find((a: any) => a.handler === 'switch');
expect(switchAction).toBeDefined();
expect(switchAction.cases).toBeDefined();
for (const item of items) {
expect(
switchAction.cases[item.value],
`switch cases에 "${item.value}" 케이스가 존재해야 함`,
).toBeDefined();
}
});
it('switch cases의 각 케이스가 sequence 객체여야 함 (배열 직접 사용 금지)', () => {
const dropdown = findComponentById(boardReports, 'mobile_bulk_action_dropdown');
const switchAction = dropdown.actions?.find((a: any) => a.handler === 'switch');
for (const [key, caseValue] of Object.entries(switchAction.cases)) {
expect(
Array.isArray(caseValue),
`"${key}" 케이스가 배열이면 안 됨 — sequence로 감싸야 함`,
).toBe(false);
expect(
(caseValue as any).handler,
`"${key}" 케이스의 handler가 "sequence"여야 함`,
).toBe('sequence');
expect(
Array.isArray((caseValue as any).actions),
`"${key}" 케이스의 actions가 배열이어야 함`,
).toBe(true);
}
});
});
@@ -0,0 +1,23 @@
{
"common": {
"$partial": "partial/en/common.json"
},
"admin": {
"$partial": "partial/en/admin.json"
},
"board": {
"$partial": "partial/en/board.json"
},
"messages": {
"$partial": "partial/en/messages.json"
},
"validation": {
"$partial": "partial/en/validation.json"
},
"attributes": {
"$partial": "partial/en/attributes.json"
},
"enums": {
"$partial": "partial/en/enums.json"
}
}
@@ -0,0 +1,53 @@
<?php
return [
'description' => [
// Board management (Admin)
'board_index' => 'Board list viewed',
'board_show' => 'Board details viewed (:board_name)',
'board_create' => 'Board created (:board_name)',
'board_update' => 'Board updated (:board_name)',
'board_delete' => 'Board deleted (:board_name)',
'board_copy' => 'Board copied (:board_name)',
'board_add_to_menu' => 'Board added to menu (:board_name)',
// Board type (Admin)
'board_type_index' => 'Board type list viewed',
'board_type_show' => 'Board type details viewed (:type_name)',
'board_type_create' => 'Board type created (:type_name)',
'board_type_update' => 'Board type updated (:type_name)',
'board_type_delete' => 'Board type deleted (:type_name)',
// Posts
'post_create' => 'Post created (Board: :board_name, Title: :title)',
'post_update' => 'Post updated (Board: :board_name, Title: :title)',
'post_delete' => 'Post deleted (Board: :board_name, ID: :post_id)',
'post_blind' => 'Post blinded (Board: :board_name, ID: :post_id)',
'post_restore' => 'Post restored (Board: :board_name, ID: :post_id)',
// Comments
'comment_create' => 'Comment created (Board: :board_name, Post: :post_id)',
'comment_update' => 'Comment updated (ID: :comment_id)',
'comment_delete' => 'Comment deleted (ID: :comment_id)',
'comment_blind' => 'Comment blinded (ID: :comment_id)',
'comment_restore' => 'Comment restored (ID: :comment_id)',
// Attachments
'board_attachment_upload' => 'Attachment uploaded (Post: :post_id)',
'board_attachment_delete' => 'Attachment deleted (Post: :post_id)',
'board_attachment_reorder' => 'Attachment order changed (Post: :post_id)',
// Report management (Admin)
'report_create' => 'Report created (ID: :report_id)',
'report_update_status' => 'Report status changed (ID: :report_id)',
'report_bulk_update_status' => 'Report statuses bulk changed (:count items)',
'report_delete' => 'Report deleted (ID: :report_id)',
'report_restore_content' => 'Report content restored (ID: :report_id)',
'report_blind_content' => 'Report content blinded (ID: :report_id)',
'report_delete_content' => 'Report content deleted (ID: :report_id)',
// Board settings
'board_settings_index' => 'Board settings viewed',
'board_settings_bulk_apply' => 'Board settings bulk applied',
],
];
@@ -0,0 +1,23 @@
{
"common": {
"$partial": "partial/ko/common.json"
},
"admin": {
"$partial": "partial/ko/admin.json"
},
"board": {
"$partial": "partial/ko/board.json"
},
"messages": {
"$partial": "partial/ko/messages.json"
},
"validation": {
"$partial": "partial/ko/validation.json"
},
"attributes": {
"$partial": "partial/ko/attributes.json"
},
"enums": {
"$partial": "partial/ko/enums.json"
}
}
@@ -0,0 +1,53 @@
<?php
return [
'description' => [
// 게시판 관리 (Admin)
'board_index' => '게시판 목록 조회',
'board_show' => '게시판 상세 조회 (:board_name)',
'board_create' => '게시판 생성 (:board_name)',
'board_update' => '게시판 수정 (:board_name)',
'board_delete' => '게시판 삭제 (:board_name)',
'board_copy' => '게시판 복사 (:board_name)',
'board_add_to_menu' => '게시판 메뉴 추가 (:board_name)',
// 게시판 유형 (Admin)
'board_type_index' => '게시판 유형 목록 조회',
'board_type_show' => '게시판 유형 상세 조회 (:type_name)',
'board_type_create' => '게시판 유형 생성 (:type_name)',
'board_type_update' => '게시판 유형 수정 (:type_name)',
'board_type_delete' => '게시판 유형 삭제 (:type_name)',
// 게시물
'post_create' => '게시물 작성 (게시판: :board_name, 제목: :title)',
'post_update' => '게시물 수정 (게시판: :board_name, 제목: :title)',
'post_delete' => '게시물 삭제 (게시판: :board_name, ID: :post_id)',
'post_blind' => '게시물 블라인드 (게시판: :board_name, ID: :post_id)',
'post_restore' => '게시물 복원 (게시판: :board_name, ID: :post_id)',
// 댓글
'comment_create' => '댓글 작성 (게시판: :board_name, 게시물: :post_id)',
'comment_update' => '댓글 수정 (ID: :comment_id)',
'comment_delete' => '댓글 삭제 (ID: :comment_id)',
'comment_blind' => '댓글 블라인드 (ID: :comment_id)',
'comment_restore' => '댓글 복원 (ID: :comment_id)',
// 첨부파일
'board_attachment_upload' => '첨부파일 업로드 (게시물: :post_id)',
'board_attachment_delete' => '첨부파일 삭제 (게시물: :post_id)',
'board_attachment_reorder' => '첨부파일 순서 변경 (게시물: :post_id)',
// 신고 관리 (Admin)
'report_create' => '신고 접수 (ID: :report_id)',
'report_update_status' => '신고 상태 변경 (ID: :report_id)',
'report_bulk_update_status' => '신고 일괄 상태 변경 (:count건)',
'report_delete' => '신고 삭제 (ID: :report_id)',
'report_restore_content' => '신고 콘텐츠 복원 (ID: :report_id)',
'report_blind_content' => '신고 콘텐츠 블라인드 (ID: :report_id)',
'report_delete_content' => '신고 콘텐츠 삭제 (ID: :report_id)',
// 게시판 설정
'board_settings_index' => '게시판 설정 조회',
'board_settings_bulk_apply' => '게시판 설정 일괄 적용',
],
];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,84 @@
{
"post": {
"title": "Title",
"content": "Content",
"category": "Category",
"is_notice": "Notice",
"is_secret": "Secret",
"content_mode": "Content Mode",
"status": "Status",
"author_name": "Author Name",
"password": "Password"
},
"report": {
"status": "Status",
"process_note": "Reason"
},
"comment": {
"content": "Comment Content",
"parent_id": "Parent Comment",
"is_secret": "Secret Comment",
"status": "Status",
"user_id": "Author",
"author_name": "Author Name",
"password": "Password",
"ip_address": "IP Address"
},
"settings": {
"basic_defaults.type": "Board Type",
"basic_defaults.per_page": "Posts Per Page",
"basic_defaults.per_page_mobile": "Posts Per Page (Mobile)",
"basic_defaults.order_by": "Sort By",
"basic_defaults.order_direction": "Sort Direction",
"basic_defaults.secret_mode": "Secret Mode",
"basic_defaults.use_comment": "Enable Comments",
"basic_defaults.use_reply": "Enable Replies",
"basic_defaults.max_reply_depth": "Max Reply Depth",
"basic_defaults.max_comment_depth": "Max Comment Depth",
"basic_defaults.comment_order": "Comment Order",
"basic_defaults.show_view_count": "Show View Count",
"basic_defaults.use_report": "Enable Reports",
"basic_defaults.min_title_length": "Min Title Length",
"basic_defaults.max_title_length": "Max Title Length",
"basic_defaults.min_content_length": "Min Content Length",
"basic_defaults.max_content_length": "Max Content Length",
"basic_defaults.min_comment_length": "Min Comment Length",
"basic_defaults.max_comment_length": "Max Comment Length",
"basic_defaults.use_file_upload": "Enable File Upload",
"basic_defaults.max_file_size": "Max File Size",
"basic_defaults.max_file_count": "Max File Count",
"basic_defaults.allowed_extensions": "Allowed Extensions",
"basic_defaults.notify_admin_on_post": "Notify Admin on Post",
"basic_defaults.notify_author": "Notify Author",
"basic_defaults.new_display_hours": "New Post Display Hours",
"basic_defaults.default_board_permissions": "Default Board Permissions",
"basic_defaults.default_board_permissions.admin": "Admin Permissions",
"basic_defaults.default_board_permissions.admin.*": "Admin Permission Role",
"basic_defaults.default_board_permissions.posts": "Post Permissions",
"basic_defaults.default_board_permissions.posts.*": "Post Permission Role",
"basic_defaults.default_board_permissions.comments": "Comment Permissions",
"basic_defaults.default_board_permissions.comments.*": "Comment Permission Role",
"basic_defaults.default_board_permissions.attachments": "Attachment Permissions",
"basic_defaults.default_board_permissions.attachments.*": "Attachment Permission Role",
"basic_defaults.default_board_permissions.manager": "Board Manager Permissions",
"basic_defaults.default_board_permissions.manager.*": "Board Manager Permission Role",
"display.date_display_format": "Date Display Format",
"report_policy.auto_hide_threshold": "Auto Hide Threshold",
"report_policy.auto_hide_target": "Auto Hide Target",
"report_policy.daily_report_limit": "Daily Report Limit",
"report_policy.rejection_limit_count": "Rejection Limit Count",
"report_policy.rejection_limit_days": "Rejection Limit (Days)",
"spam_security.blocked_keywords": "Blocked Keywords",
"spam_security.post_cooldown_seconds": "Post Cooldown (Seconds)",
"spam_security.comment_cooldown_seconds": "Comment Cooldown (Seconds)",
"spam_security.view_count_method": "View Count Method",
"spam_security.view_count_cache_ttl": "View Count Cache TTL (Seconds)"
},
"bulk_apply": {
"fields": "Fields",
"fields.*": "Field Item",
"apply_all": "Apply All",
"board_ids": "Board List",
"board_ids.*": "Board ID"
}
}
@@ -0,0 +1,55 @@
{
"parent_post": "Original Post",
"view_parent_post": "View Original Post",
"view_detail": "View Detail",
"show_more": "Show more",
"show_less": "Show less",
"permissions": {
"admin": {
"posts": {
"read": "View Posts (Admin)",
"read_desc": "Can view post list and details in admin page.",
"write": "Create/Edit/Delete Posts (Admin)",
"write_desc": "Can create posts and edit/delete all posts in admin page.",
"read-secret": "View Secret Posts (Admin)",
"read-secret_desc": "Can view secret posts without entering password."
},
"comments": {
"read": "View Comments (Admin)",
"read_desc": "Can view all comments in admin page.",
"write": "Create/Edit/Delete Comments (Admin)",
"write_desc": "Can create comments and edit/delete all comments in admin page."
},
"attachments": {
"upload": "Upload/Delete Files (Admin)",
"upload_desc": "Can attach files to posts and delete all attachments.",
"download": "Download Files (Admin)",
"download_desc": "Can download all attachments."
},
"manage": "Manage Board (Admin)",
"manage_desc": "Can manage others' posts/comments, blind, restore, and set notices."
},
"posts": {
"read": "View Posts",
"read_desc": "Can view post list and details.",
"write": "Create/Edit/Delete Posts",
"write_desc": "Can create posts and edit/delete own posts.",
"read-secret": "View Secret Posts",
"read-secret_desc": "Can view secret posts without entering password."
},
"comments": {
"read": "View Comments",
"read_desc": "Can view comment list.",
"write": "Create/Edit/Delete Comments",
"write_desc": "Can create comments and edit/delete own comments."
},
"attachments": {
"upload": "Upload/Delete Files",
"upload_desc": "Can attach files to posts and delete own attachments.",
"download": "Download Files",
"download_desc": "Can download attached files."
},
"manager": "Manage Board (User)",
"manager_desc": "Can blind, restore, and delete others' posts/comments in user page."
}
}
@@ -0,0 +1,24 @@
{
"save": "Save",
"saving": "Saving...",
"loading": "Loading...",
"cancel": "Cancel",
"refresh": "Refresh",
"search": "Search",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"deleting": "Deleting...",
"view": "View",
"copy": "Copy",
"confirm": "Confirm",
"close": "Close",
"continue": "Continue",
"blind": "Blind",
"restore": "Restore",
"back": "Back",
"new": "New",
"pagination_info": "Showing {{from}}-{{to}} of {{total}} users",
"error_occurred": "An error has occurred",
"optional": "Optional"
}
@@ -0,0 +1,29 @@
{
"report_status": {
"pending": "Pending",
"review": "Under Review",
"rejected": "Rejected",
"suspended": "Suspended",
"deleted": "Permanently Deleted"
},
"trigger_type": {
"report": "Report",
"admin": "Admin",
"system": "System"
},
"report_type": {
"post": "Post",
"comment": "Comment"
},
"report_reason_type": {
"abuse": "Abuse/Insult",
"hate_speech": "Hate Speech",
"spam": "Spam/Advertisement",
"copyright": "Copyright Violation",
"privacy": "Privacy Violation",
"misinformation": "Misinformation/Fake News",
"sexual": "Sexual Content",
"violence": "Violent Content",
"other": "Other"
}
}
@@ -0,0 +1,70 @@
{
"common": {
"system": "System"
},
"boards": {
"menu_added_success": "Successfully added to admin menu.",
"menu_add_failed": "Failed to add menu.",
"menu_already_exists": "Menu already added.",
"module_not_found": "Board module not found."
},
"post": {
"notice": "Notice",
"reply": "Reply",
"action": {
"blind": "Blinded",
"delete": "Deleted",
"restore": "Restored"
},
"deleted_post_title": "This post has been deleted",
"deleted_post_content": "This post has been deleted.",
"blinded_post_title": "This post has been blinded",
"blinded_post_content": "This post has been blinded."
},
"comment": {
"create_success": "Comment has been created.",
"update_success": "Comment has been updated.",
"delete_success": "Comment has been deleted.",
"blind_success": "Comment has been blinded.",
"restore_success": "Comment has been restored.",
"not_found": "Comment not found.",
"not_guest_comment": "This is not a guest comment.",
"invalid_password": "Password does not match.",
"password_verified": "Password has been verified.",
"verify_password_failed": "Password verification failed.",
"deleted_comment_content": "This comment has been deleted.",
"blinded_comment_content": "This comment has been blinded.",
"view_original": "View Original",
"hide_original": "Hide Original",
"post_blinded": "Comments cannot be posted on a blinded post.",
"post_deleted": "Comments cannot be posted on a deleted post."
},
"reports": {
"create_success": "Report has been submitted.",
"create_failed": "Failed to submit report.",
"duplicate_report": "You have already reported this.",
"report_disabled": "Reporting is disabled for this board.",
"target_not_reportable": "This target cannot be reported."
},
"board_type": {
"list": "Board types retrieved successfully.",
"created": "Board type has been created.",
"updated": "Board type has been updated.",
"deleted": "Board type has been deleted.",
"default_set": "Default board type has been set.",
"not_found": "Board type not found.",
"in_use": "Cannot delete a board type that is in use.",
"delete_in_use": "Cannot delete this board type. It is used by :count board(s)."
},
"settings": {
"fetch_success": "Settings loaded successfully.",
"fetch_failed": "Failed to load settings.",
"save_success": "Settings saved successfully.",
"save_failed": "Failed to save settings.",
"save_error": "An error occurred while saving settings.",
"bulk_apply_success": "Settings applied to :count boards.",
"bulk_apply_failed": "Failed to bulk apply settings.",
"clear_cache_success": "Settings cache cleared successfully.",
"clear_cache_error": "An error occurred while clearing settings cache."
}
}
@@ -0,0 +1,10 @@
{
"spam": "Spam/Ads",
"abuse": "Abuse/Insult",
"obscenity": "Obscenity",
"illegal": "Illegal Content",
"hate_speech": "Hate Speech",
"privacy": "Privacy Violation",
"copyright": "Copyright Infringement",
"other": "Other"
}
@@ -0,0 +1,132 @@
{
"post": {
"title": {
"required": "Title is required.",
"min": "Title must be at least :min characters.",
"max": "Title can be up to :max characters."
},
"content": {
"required": "Content is required.",
"min": "Content must be at least :min characters.",
"max": "Content can be up to :max characters."
},
"category": {
"max": "Category can be up to :max characters."
},
"status": {
"in": "Invalid status."
},
"author_name": {
"max": "Author name can be up to :max characters."
},
"password": {
"min": "Password must be at least :min characters."
},
"parent_id": {
"not_found": "Original post not found.",
"blinded": "You cannot reply to a blinded post.",
"deleted": "You cannot reply to a deleted post.",
"exists": "The specified post does not exist."
},
"files": {
"array": "Files must be an array.",
"max": "You can upload up to :max files.",
"file": "Must be a valid file.",
"file_max": "File size cannot exceed :maxKB.",
"mimes": "File extension not allowed."
}
},
"comment": {
"content": {
"required": "Comment content is required.",
"min": "Comment must be at least :min characters.",
"max": "Comment can be up to :max characters."
},
"post_id": {
"not_found": "Post not found.",
"blinded": "You cannot comment on a blinded post.",
"deleted": "You cannot comment on a deleted post."
},
"parent_id": {
"integer": "Parent comment ID must be an integer.",
"not_found": "Parent comment not found.",
"blinded": "You cannot reply to a blinded comment.",
"deleted": "You cannot reply to a deleted comment."
},
"user_id": {
"exists": "User does not exist."
},
"author_name": {
"max": "Author name can be up to :max characters."
},
"password": {
"min": "Password must be at least :min characters."
},
"ip_address": {
"required": "IP address is required."
}
},
"board": {
"not_found": "Board not found."
},
"board_type": {
"slug_required": "Slug is required.",
"slug_format": "Slug must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.",
"slug_unique": "This slug is already in use.",
"name_required": "Type name is required.",
"name_ko_required": "Korean type name is required."
},
"report": {
"status": {
"required": "Status is required.",
"in": "Invalid status."
},
"process_note": {
"max": "Reason can be up to 1000 characters."
},
"invalid_status_transition": "Cannot change to this status from current status."
},
"max_reply_depth": {
"min": "Max reply depth must be at least :min.",
"max": "Max reply depth can be up to :max."
},
"max_comment_depth": {
"min": "Max comment depth must be at least :min.",
"max": "Max comment depth can be up to :max."
},
"notify_author_channels": {
"array": "Author notification channels must be an array.",
"item_max": "Channel name can be up to :max characters."
},
"notify_admin_on_post_channels": {
"array": "Admin notification channels must be an array.",
"item_max": "Channel name can be up to :max characters."
},
"settings": {
"category": {
"required": "Category is required.",
"in": "Invalid category."
}
},
"bulk_apply": {
"fields": {
"required": "Please select fields to apply.",
"min": "Please select at least one field."
},
"fields_item": {
"in": "Invalid field."
},
"apply_all": {
"required": "Apply all option is required.",
"boolean": "Apply all must be true or false."
},
"board_ids": {
"required_if": "Please select boards when not applying to all.",
"array": "Board IDs must be an array."
},
"board_ids_item": {
"integer": "Board ID must be an integer.",
"exists": "Board does not exist."
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,84 @@
{
"post": {
"title": "제목",
"content": "내용",
"category": "분류",
"is_notice": "공지글",
"is_secret": "비밀글",
"content_mode": "내용 모드",
"status": "상태",
"author_name": "작성자명",
"password": "비밀번호"
},
"report": {
"status": "상태",
"process_note": "처리 사유"
},
"comment": {
"content": "댓글 내용",
"parent_id": "부모 댓글",
"is_secret": "비밀 댓글",
"status": "상태",
"user_id": "작성자",
"author_name": "작성자명",
"password": "비밀번호",
"ip_address": "IP 주소"
},
"settings": {
"basic_defaults.type": "게시판 유형",
"basic_defaults.per_page": "페이지당 게시글 수",
"basic_defaults.per_page_mobile": "모바일 페이지당 게시글 수",
"basic_defaults.order_by": "정렬 기준",
"basic_defaults.order_direction": "정렬 방향",
"basic_defaults.secret_mode": "비밀글 모드",
"basic_defaults.use_comment": "댓글 사용",
"basic_defaults.use_reply": "답글 사용",
"basic_defaults.max_reply_depth": "최대 답글 깊이",
"basic_defaults.max_comment_depth": "최대 댓글 깊이",
"basic_defaults.comment_order": "댓글 정렬",
"basic_defaults.show_view_count": "조회수 표시",
"basic_defaults.use_report": "신고 기능",
"basic_defaults.min_title_length": "제목 최소 길이",
"basic_defaults.max_title_length": "제목 최대 길이",
"basic_defaults.min_content_length": "내용 최소 길이",
"basic_defaults.max_content_length": "내용 최대 길이",
"basic_defaults.min_comment_length": "댓글 최소 길이",
"basic_defaults.max_comment_length": "댓글 최대 길이",
"basic_defaults.use_file_upload": "파일 업로드 사용",
"basic_defaults.max_file_size": "최대 파일 크기",
"basic_defaults.max_file_count": "최대 파일 수",
"basic_defaults.allowed_extensions": "허용 확장자",
"basic_defaults.notify_admin_on_post": "게시글 작성 시 관리자 알림",
"basic_defaults.notify_author": "작성자 알림",
"basic_defaults.new_display_hours": "새 글 표시 시간",
"basic_defaults.default_board_permissions": "기본 게시판 권한",
"basic_defaults.default_board_permissions.admin": "관리자 권한",
"basic_defaults.default_board_permissions.admin.*": "관리자 권한 역할",
"basic_defaults.default_board_permissions.posts": "게시글 권한",
"basic_defaults.default_board_permissions.posts.*": "게시글 권한 역할",
"basic_defaults.default_board_permissions.comments": "댓글 권한",
"basic_defaults.default_board_permissions.comments.*": "댓글 권한 역할",
"basic_defaults.default_board_permissions.attachments": "첨부파일 권한",
"basic_defaults.default_board_permissions.attachments.*": "첨부파일 권한 역할",
"basic_defaults.default_board_permissions.manager": "게시판 관리 권한",
"basic_defaults.default_board_permissions.manager.*": "게시판 관리 권한 역할",
"display.date_display_format": "날짜 표시 방식",
"report_policy.auto_hide_threshold": "자동 숨김 임계값",
"report_policy.auto_hide_target": "자동 숨김 대상",
"report_policy.daily_report_limit": "일일 신고 제한",
"report_policy.rejection_limit_count": "반려 제한 횟수",
"report_policy.rejection_limit_days": "반려 제한 기간(일)",
"spam_security.blocked_keywords": "차단 키워드",
"spam_security.post_cooldown_seconds": "게시글 작성 간격(초)",
"spam_security.comment_cooldown_seconds": "댓글 작성 간격(초)",
"spam_security.view_count_method": "조회수 카운트 방식",
"spam_security.view_count_cache_ttl": "조회수 캐시 유효시간(초)"
},
"bulk_apply": {
"fields": "적용 필드",
"fields.*": "적용 필드 항목",
"apply_all": "전체 적용",
"board_ids": "게시판 목록",
"board_ids.*": "게시판 ID"
}
}
@@ -0,0 +1,55 @@
{
"parent_post": "원글",
"view_parent_post": "원글 보기",
"view_detail": "상세 보기",
"show_more": "더보기",
"show_less": "접기",
"permissions": {
"admin": {
"posts": {
"read": "게시글 조회 (관리자)",
"read_desc": "관리자 페이지에서 게시글 목록 및 상세 내용을 조회할 수 있습니다.",
"write": "게시글 작성/수정/삭제 (관리자)",
"write_desc": "관리자 페이지에서 게시글을 작성하고, 모든 게시글을 수정/삭제할 수 있습니다.",
"read-secret": "비밀글 조회 (관리자)",
"read-secret_desc": "비밀글을 비밀번호 입력 없이 열람할 수 있습니다."
},
"comments": {
"read": "댓글 조회 (관리자)",
"read_desc": "관리자 페이지에서 모든 댓글을 조회할 수 있습니다.",
"write": "댓글 작성/수정/삭제 (관리자)",
"write_desc": "관리자 페이지에서 댓글을 작성하고, 모든 댓글을 수정/삭제할 수 있습니다."
},
"attachments": {
"upload": "파일 업로드/삭제 (관리자)",
"upload_desc": "게시글에 파일을 첨부하고, 모든 첨부파일을 삭제할 수 있습니다.",
"download": "파일 다운로드 (관리자)",
"download_desc": "모든 첨부파일을 다운로드할 수 있습니다."
},
"manage": "게시판 관리 (관리자)",
"manage_desc": "타인 게시글/댓글 관리, 블라인드, 복원, 공지 설정을 할 수 있습니다."
},
"posts": {
"read": "게시글 조회",
"read_desc": "게시글 목록 및 상세 내용을 조회할 수 있습니다.",
"write": "게시글 작성/수정/삭제",
"write_desc": "게시글을 작성하고 본인의 글을 수정/삭제할 수 있습니다.",
"read-secret": "비밀글 조회",
"read-secret_desc": "비밀글을 비밀번호 입력 없이 열람할 수 있습니다."
},
"comments": {
"read": "댓글 조회",
"read_desc": "댓글 목록을 조회할 수 있습니다.",
"write": "댓글 작성/수정/삭제",
"write_desc": "댓글을 작성하고 본인의 댓글을 수정/삭제할 수 있습니다."
},
"attachments": {
"upload": "파일 업로드/삭제",
"upload_desc": "게시글에 파일을 첨부하고, 본인이 첨부한 파일을 삭제할 수 있습니다.",
"download": "파일 다운로드",
"download_desc": "첨부파일을 다운로드할 수 있습니다."
},
"manager": "게시판 관리 (사용자)",
"manager_desc": "사용자 페이지에서 타인의 게시글/댓글을 블라인드, 복원, 삭제할 수 있습니다."
}
}
@@ -0,0 +1,24 @@
{
"save": "저장",
"saving": "저장 중...",
"loading": "로딩 중...",
"cancel": "취소",
"refresh": "새로고침",
"search": "검색",
"add": "추가",
"edit": "수정",
"delete": "삭제",
"deleting": "삭제 중...",
"view": "보기",
"copy": "복사",
"confirm": "확인",
"close": "닫기",
"continue": "계속하기",
"blind": "블라인드",
"restore": "복원",
"back": "뒤로가기",
"new": "New",
"pagination_info": "총 {{total}}명 중 {{from}}-{{to}}명 표시",
"error_occurred": "오류가 발생했습니다",
"optional": "선택"
}
@@ -0,0 +1,29 @@
{
"report_status": {
"pending": "접수",
"review": "검토",
"rejected": "반려",
"suspended": "게시중단",
"deleted": "영구 삭제"
},
"trigger_type": {
"report": "신고",
"admin": "관리자",
"system": "시스템"
},
"report_type": {
"post": "게시글",
"comment": "댓글"
},
"report_reason_type": {
"abuse": "욕설/비방",
"hate_speech": "혐오 발언",
"spam": "스팸/광고",
"copyright": "저작권 침해",
"privacy": "개인정보 노출",
"misinformation": "허위정보/가짜뉴스",
"sexual": "성적인 콘텐츠",
"violence": "폭력적인 콘텐츠",
"other": "기타"
}
}
@@ -0,0 +1,70 @@
{
"common": {
"system": "시스템"
},
"boards": {
"menu_added_success": "관리자 메뉴에 추가되었습니다.",
"menu_add_failed": "메뉴 추가에 실패했습니다.",
"menu_already_exists": "이미 추가된 메뉴입니다.",
"module_not_found": "게시판 모듈을 찾을 수 없습니다."
},
"post": {
"notice": "공지",
"reply": "답변",
"action": {
"blind": "블라인드 처리",
"delete": "삭제 처리",
"restore": "복원 처리"
},
"deleted_post_title": "삭제된 게시글입니다",
"deleted_post_content": "이 게시글은 삭제되었습니다.",
"blinded_post_title": "블라인드 처리된 게시글입니다",
"blinded_post_content": "이 게시글은 블라인드 처리되었습니다."
},
"comment": {
"create_success": "댓글이 등록되었습니다.",
"update_success": "댓글이 수정되었습니다.",
"delete_success": "댓글이 삭제되었습니다.",
"blind_success": "댓글이 블라인드 처리되었습니다.",
"restore_success": "댓글이 복원되었습니다.",
"not_found": "댓글을 찾을 수 없습니다.",
"not_guest_comment": "비회원 댓글이 아닙니다.",
"invalid_password": "비밀번호가 일치하지 않습니다.",
"password_verified": "비밀번호가 확인되었습니다.",
"verify_password_failed": "비밀번호 확인에 실패했습니다.",
"deleted_comment_content": "삭제된 댓글입니다",
"blinded_comment_content": "블라인드 처리된 댓글입니다",
"view_original": "원문 보기",
"hide_original": "원문 숨기기",
"post_blinded": "블라인드 처리된 게시글에는 댓글을 작성할 수 없습니다.",
"post_deleted": "삭제된 게시글에는 댓글을 작성할 수 없습니다."
},
"reports": {
"create_success": "신고가 접수되었습니다.",
"create_failed": "신고 접수에 실패했습니다.",
"duplicate_report": "이미 신고한 대상입니다.",
"report_disabled": "이 게시판에서는 신고 기능이 비활성화되어 있습니다.",
"target_not_reportable": "신고할 수 없는 대상입니다."
},
"board_type": {
"list": "게시판 유형 목록을 조회했습니다.",
"created": "게시판 유형이 생성되었습니다.",
"updated": "게시판 유형이 수정되었습니다.",
"deleted": "게시판 유형이 삭제되었습니다.",
"default_set": "기본 게시판 유형이 설정되었습니다.",
"not_found": "게시판 유형을 찾을 수 없습니다.",
"in_use": "사용 중인 게시판 유형은 삭제할 수 없습니다.",
"delete_in_use": ":count개 게시판에서 사용 중인 유형은 삭제할 수 없습니다."
},
"settings": {
"fetch_success": "설정을 조회했습니다.",
"fetch_failed": "설정 조회에 실패했습니다.",
"save_success": "설정이 저장되었습니다.",
"save_failed": "설정 저장에 실패했습니다.",
"save_error": "설정 저장 중 오류가 발생했습니다.",
"bulk_apply_success": ":count개 게시판에 설정이 일괄 적용되었습니다.",
"bulk_apply_failed": "설정 일괄 적용에 실패했습니다.",
"clear_cache_success": "설정 캐시가 초기화되었습니다.",
"clear_cache_error": "설정 캐시 초기화 중 오류가 발생했습니다."
}
}
@@ -0,0 +1,10 @@
{
"spam": "스팸/광고",
"abuse": "욕설/비방",
"obscenity": "음란물",
"illegal": "불법정보",
"hate_speech": "혐오 발언",
"privacy": "개인정보 노출",
"copyright": "저작권 침해",
"other": "기타"
}
@@ -0,0 +1,132 @@
{
"post": {
"title": {
"required": "제목은 필수입니다.",
"min": "제목은 최소 :min자 이상이어야 합니다.",
"max": "제목은 최대 :max자까지 입력 가능합니다."
},
"content": {
"required": "내용은 필수입니다.",
"min": "내용은 최소 :min자 이상이어야 합니다.",
"max": "내용은 최대 :max자까지 입력 가능합니다."
},
"category": {
"max": "분류는 최대 :max자까지 입력 가능합니다."
},
"status": {
"in": "유효하지 않은 상태입니다."
},
"author_name": {
"max": "작성자명은 최대 :max자까지 입력 가능합니다."
},
"password": {
"min": "비밀번호는 최소 :min자 이상이어야 합니다."
},
"parent_id": {
"not_found": "원글을 찾을 수 없습니다.",
"blinded": "블라인드 처리된 게시글에는 답글을 작성할 수 없습니다.",
"deleted": "삭제된 게시글에는 답글을 작성할 수 없습니다.",
"exists": "존재하지 않는 게시글입니다."
},
"files": {
"array": "파일은 배열이어야 합니다.",
"max": "최대 :max개의 파일만 업로드 가능합니다.",
"file": "유효한 파일이어야 합니다.",
"file_max": "파일 용량은 :maxKB를 초과할 수 없습니다.",
"mimes": "허용된 확장자가 아닙니다."
}
},
"comment": {
"content": {
"required": "댓글 내용은 필수입니다.",
"min": "댓글은 최소 :min자 이상이어야 합니다.",
"max": "댓글은 최대 :max자까지 입력 가능합니다."
},
"post_id": {
"not_found": "게시글을 찾을 수 없습니다.",
"blinded": "블라인드 처리된 게시글에는 댓글을 작성할 수 없습니다.",
"deleted": "삭제된 게시글에는 댓글을 작성할 수 없습니다."
},
"parent_id": {
"integer": "부모 댓글 ID는 정수여야 합니다.",
"not_found": "부모 댓글을 찾을 수 없습니다.",
"blinded": "블라인드 처리된 댓글에는 답글을 작성할 수 없습니다.",
"deleted": "삭제된 댓글에는 답글을 작성할 수 없습니다."
},
"user_id": {
"exists": "존재하지 않는 사용자입니다."
},
"author_name": {
"max": "작성자명은 최대 :max자까지 입력 가능합니다."
},
"password": {
"min": "비밀번호는 최소 :min자 이상이어야 합니다."
},
"ip_address": {
"required": "IP 주소는 필수입니다."
}
},
"board": {
"not_found": "게시판을 찾을 수 없습니다."
},
"board_type": {
"slug_required": "슬러그는 필수입니다.",
"slug_format": "슬러그는 소문자, 숫자, 하이픈만 사용할 수 있으며 소문자로 시작해야 합니다.",
"slug_unique": "이미 사용 중인 슬러그입니다.",
"name_required": "유형명은 필수입니다.",
"name_ko_required": "한국어 유형명은 필수입니다."
},
"report": {
"status": {
"required": "상태는 필수입니다.",
"in": "유효하지 않은 상태입니다."
},
"process_note": {
"max": "처리 사유는 최대 1000자까지 입력 가능합니다."
},
"invalid_status_transition": "현재 상태에서 해당 상태로 변경할 수 없습니다."
},
"max_reply_depth": {
"min": "답변글 최대 깊이는 최소 :min 이상이어야 합니다.",
"max": "답변글 최대 깊이는 최대 :max까지 설정 가능합니다."
},
"max_comment_depth": {
"min": "대댓글 최대 깊이는 최소 :min 이상이어야 합니다.",
"max": "대댓글 최대 깊이는 최대 :max까지 설정 가능합니다."
},
"notify_author_channels": {
"array": "작성자 알림 채널은 배열이어야 합니다.",
"item_max": "알림 채널명은 최대 :max자까지 입력 가능합니다."
},
"notify_admin_on_post_channels": {
"array": "관리자 알림 채널은 배열이어야 합니다.",
"item_max": "알림 채널명은 최대 :max자까지 입력 가능합니다."
},
"settings": {
"category": {
"required": "카테고리는 필수입니다.",
"in": "유효하지 않은 카테고리입니다."
}
},
"bulk_apply": {
"fields": {
"required": "적용할 필드를 선택해주세요.",
"min": "최소 1개 이상의 필드를 선택해주세요."
},
"fields_item": {
"in": "유효하지 않은 필드입니다."
},
"apply_all": {
"required": "전체 적용 여부는 필수입니다.",
"boolean": "전체 적용 여부는 true/false여야 합니다."
},
"board_ids": {
"required_if": "특정 게시판 적용 시 게시판을 선택해주세요.",
"array": "게시판 ID는 배열이어야 합니다."
},
"board_ids_item": {
"integer": "게시판 ID는 정수여야 합니다.",
"exists": "존재하지 않는 게시판입니다."
}
}
}
@@ -0,0 +1,494 @@
{
"version": "1.0.0",
"layout_name": "admin_board_form",
"permissions": [
"sirsoft-board.boards.read"
],
"extends": "_admin_base",
"meta": {
"title": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}",
"description": "$t:sirsoft-board.admin.form.description"
},
"state": {
"activeTab": "basic",
"showCategoryRemoveModal": false,
"categoryToRemove": null,
"isSaving": false,
"hasChanges": false,
"showCancelConfirmModal": false,
"form": [],
"isDataLoaded": false,
"managerSearchResults": [],
"stepSearchResults": []
},
"data_sources": [
{
"id": "boards",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards/form-data",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"cache": false,
"loading_strategy": "blocking",
"params": {
"board_id": "{{route?.id}}",
"copy_id": "{{query?.copy_id}}"
},
"initLocal": "form",
"refetchOnMount": true,
"errorHandling": {
"403": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
},
"404": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
}
}
},
{
"id": "roles",
"type": "api",
"endpoint": "/api/admin/roles/active",
"method": "GET",
"auto_fetch": true,
"auth_required": true
},
{
"id": "notification_channels",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/notification-channels",
"method": "GET",
"auto_fetch": true,
"auth_required": true
}
],
"computed": {
"isReadOnly": "{{!!route?.id && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
},
"slots": {
"content": [
{
"id": "board_form_wrapper",
"type": "basic",
"name": "Div",
"blur_until_loaded": true,
"dataKey": "form",
"trackChanges": true,
"children": [
{
"id": "read_only_banner",
"type": "basic",
"name": "Div",
"if": "{{_computed.isReadOnly}}",
"props": {
"className": "mx-6 mt-4 flex items-center gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "lock",
"className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-board.admin.form.read_only_message"
}
]
},
{
"id": "sticky_header_container",
"type": "basic",
"name": "Div",
"props": {
"className": "sticky top-0 z-40 bg-gray-50 dark:bg-gray-900"
},
"children": [
{
"id": "form_header",
"type": "basic",
"name": "Div",
"props": {
"className": "border-b border-gray-200 dark:border-gray-700 py-3 px-6"
},
"children": [
{
"id": "header_flex_container",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between items-center"
},
"children": [
{
"id": "header_title",
"type": "basic",
"name": "H1",
"props": {
"className": "text-xl font-semibold text-gray-900 dark:text-white"
},
"text": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
},
{
"id": "header_actions",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3"
},
"children": [
{
"id": "cancel_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"target": "/admin/boards"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.common.cancel"
}
]
},
{
"id": "save_button",
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"isSaving": true
}
},
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/boards/' + route.id : '/api/modules/sirsoft-board/admin/boards'}}",
"params": {
"method": "{{route?.id ? 'PUT' : 'POST'}}",
"body": "{{_local.form}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false,
"errors": null
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{route?.id ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
}
},
{
"handler": "navigate",
"condition": "{{!route?.id}}",
"params": {
"path": "{{'/admin/boards/' + result.data.id + '/edit'}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}",
"icon": "triangle-exclamation"
}
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{!_local.isSaving}}",
"props": {
"name": "floppy-disk",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isSaving}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_local.isSaving ? '$t:sirsoft-board.common.saving' : '$t:sirsoft-board.common.save'}}"
}
]
}
]
}
]
}
]
},
{
"id": "tab_navigation",
"type": "composite",
"name": "TabNavigationScroll",
"props": {
"activeTabId": "{{_local.activeTab ?? 'basic'}}",
"enableScrollSpy": true,
"scrollSpyOffset": 120,
"tabs": [
{
"id": "basic",
"label": "$t:sirsoft-board.admin.form.tabs.basic"
},
{
"id": "permissions",
"label": "$t:sirsoft-board.admin.form.tabs.permissions"
},
{
"id": "list",
"label": "$t:sirsoft-board.admin.form.tabs.list"
},
{
"id": "post",
"label": "$t:sirsoft-board.admin.form.tabs.post"
},
{
"id": "notification",
"label": "$t:sirsoft-board.admin.form.tabs.notification"
}
]
}
}
]
},
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "px-6 pt-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-board.admin.form.validation_error"
},
{
"type": "basic",
"name": "P",
"if": "{{typeof _local.errors === 'string'}}",
"props": {
"className": "mt-2 text-sm text-red-700 dark:text-red-300"
},
"text": "{{_local.errors}}"
},
{
"type": "basic",
"name": "Ul",
"if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}",
"props": {
"className": "mt-2 list-disc list-inside space-y-1"
},
"iteration": {
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
"item_var": "message"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
},
"text": "{{message}}"
}
]
}
]
}
]
}
]
},
{
"id": "board_form",
"type": "basic",
"name": "Form",
"props": {
"id": "board_form",
"className": "p-6"
},
"children": [
{
"partial": "partials/admin_board_form/_tab_basic.json"
},
{
"partial": "partials/admin_board_form/_tab_permissions.json"
},
{
"partial": "partials/admin_board_form/_tab_list.json"
},
{
"partial": "partials/admin_board_form/_tab_post.json"
},
{
"partial": "partials/admin_board_form/_tab_notification.json"
}
]
}
]
}
]
},
"modals": [
{
"id": "category_remove_modal",
"type": "composite",
"name": "Modal",
"condition": "{{_local.showCategoryRemoveModal === true}}",
"props": {
"isOpen": "{{_local.showCategoryRemoveModal}}",
"title": "$t:sirsoft-board.admin.form.modals.category_remove.title",
"size": "sm"
},
"actions": [
{
"type": "close",
"handler": "setState",
"target": "_local.showCategoryRemoveModal",
"value": false
}
],
"children": [
{
"id": "category_remove_modal_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"id": "category_remove_modal_message",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.form.modals.category_remove.message"
},
{
"id": "category_remove_modal_warning",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.form.modals.category_remove.warning"
},
{
"id": "category_remove_modal_actions",
"type": "basic",
"name": "Div",
"props": {
"className": "flex mt-6 justify-end"
},
"children": [
{
"id": "category_remove_modal_confirm",
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors"
},
"text": "$t:common.confirm",
"actions": [
{
"type": "click",
"handler": "setState",
"target": "_local.showCategoryRemoveModal",
"value": false
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,748 @@
{
"version": "1.0.0",
"layout_name": "admin_board_index",
"permissions": [
"sirsoft-board.boards.read"
],
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-board.admin.board.index.title",
"description": "$t:sirsoft-board.admin.board.index.description"
},
"data_sources": [
{
"id": "boards",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"refetchOnMount": true,
"loading_strategy": "progressive",
"params": {
"page": "{{query.page}}",
"per_page": "{{query.per_page ?? 10}}",
"search": "{{query.search}}"
},
"errorHandling": {
"403": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
}
}
}
],
"slots": {
"content": [
{
"id": "board_list_content",
"type": "basic",
"name": "Div",
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900"
},
"children": [
{
"id": "page_header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "$t:sirsoft-board.admin.board.index.title",
"description": "$t:sirsoft-board.admin.board.index.description"
},
"children": [
{
"id": "refresh_button",
"type": "basic",
"name": "Button",
"props": {
"className": "p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-800 transition-colors",
"aria-label": "$t:common.refresh"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards",
"mergeQuery": false
}
}
],
"children": [
{
"id": "refresh_icon",
"type": "basic",
"name": "Icon",
"props": {
"name": "refresh",
"className": "w-5 h-5"
}
}
]
},
{
"id": "add_board_button",
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{boards?.data?.abilities?.can_create !== true}}",
"className": "flex items-center gap-2 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"target": "/admin/boards/create"
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "plus",
"className": "w-5 h-5"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.board.index.add_board"
}
]
}
]
},
{
"id": "content_area",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
},
"children": [
{
"id": "search_area",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end mb-6"
},
"children": [
{
"id": "search_bar",
"type": "composite",
"name": "SearchBar",
"props": {
"name": "search",
"placeholder": "$t:sirsoft-board.admin.board.index.search_placeholder",
"value": "{{query.search || ''}}",
"className": "w-64"
},
"actions": [
{
"type": "submit",
"handler": "navigate",
"params": {
"path": "/admin/boards",
"mergeQuery": true,
"query": {
"page": 1,
"search": "{{$event.target.search.value}}"
}
}
}
]
}
]
},
{
"id": "boards_card_grid",
"type": "composite",
"name": "CardGrid",
"blur_until_loaded": true,
"props": {
"data": "{{boards.data.data}}",
"gridColumns": 1,
"gap": 4,
"pagination": true,
"serverSidePagination": true,
"serverCurrentPage": "{{boards?.data?.pagination?.current_page ?? 1}}",
"serverTotalPages": "{{boards?.data?.pagination?.last_page ?? 1}}",
"alwaysShowPagination": true,
"emptyMessage": "$t:sirsoft-board.admin.board.index.empty_description",
"cardClassName": "rounded-lg bg-white dark:bg-gray-800 shadow-md hover:shadow-lg transition-shadow border border-gray-200 dark:border-gray-700 p-6 space-y-4",
"cardColumns": [
{
"id": "board_card",
"cellChildren": [
{
"id": "card_header_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start justify-between"
},
"children": [
{
"id": "header_left",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3"
},
"children": [
{
"id": "board_slug_badge",
"type": "composite",
"name": "StatusBadge",
"props": {
"status": "info",
"label": "{{row.slug}}",
"showIcon": false
}
},
{
"id": "board_name",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "{{row.name}}"
},
{
"id": "board_type",
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "({{row.type}})"
}
]
},
{
"id": "header_right",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3"
},
"children": [
{
"id": "active_toggle",
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{row.is_active}}",
"name": "is_active",
"size": "sm",
"disabled": "{{row.abilities?.can_update !== true}}"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/boards/{{row.id}}",
"params": {
"method": "PUT",
"body": {
"is_active": "{{!row.is_active}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boards",
"sync": true
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{row.is_active ? '$t:sirsoft-board.admin.board.messages.deactivated' : '$t:sirsoft-board.admin.board.messages.activated'}}"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
}
]
},
{
"id": "action_menu",
"type": "composite",
"name": "ActionMenu",
"props": {
"triggerLabel": "",
"triggerIconName": "ellipsis-h",
"position": "right",
"items": [
{ "id": "edit", "label": "$t:sirsoft-board.admin.board.actions.edit", "iconName": "pencil", "disabled": "{{row.abilities?.can_update !== true}}" },
{ "id": "manage_posts", "label": "$t:sirsoft-board.admin.board.actions.manage_posts", "iconName": "list" },
{ "id": "copy", "label": "$t:sirsoft-board.admin.board.actions.copy", "iconName": "copy", "disabled": "{{row.abilities?.can_create !== true}}" },
{ "id": "delete", "label": "$t:sirsoft-board.admin.board.actions.delete", "iconName": "trash", "variant": "danger", "disabled": "{{row.abilities?.can_delete !== true}}" }
]
},
"actions": [
{
"event": "onItemClick",
"handler": "switch",
"value": "{{$args[0]}}",
"cases": {
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/boards/{{row.id}}/edit",
"mergeQuery": false,
"query": []
}
},
"manage_posts": {
"handler": "navigate",
"params": {
"path": "/admin/board/{{row.slug}}"
}
},
"copy": {
"handler": "setState",
"params": {
"target": "global",
"modal_data": "{{row}}"
},
"onSuccess": [
{
"handler": "openModal",
"target": "copy_confirm_modal"
}
]
},
"delete": {
"handler": "setState",
"params": {
"target": "global",
"modal_data": "{{row}}"
},
"onSuccess": [
{
"handler": "openModal",
"target": "delete_confirm_modal"
}
]
}
}
}
]
}
]
}
]
},
{
"id": "board_meta_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
},
"children": [
{
"id": "post_count_label",
"type": "basic",
"name": "Span",
"props": {
"className": "font-semibold"
},
"text": "$t:sirsoft-board.admin.board.index.post_count"
},
{
"id": "post_count_value",
"type": "basic",
"name": "Span",
"text": "{{row?.posts_count ?? 0}}"
},
{
"id": "separator",
"type": "basic",
"name": "Span",
"props": {
"className": "mx-1"
},
"text": "·"
},
{
"id": "created_date_label",
"type": "basic",
"name": "Span",
"props": {
"className": "font-semibold"
},
"text": "$t:sirsoft-board.admin.board.index.created_at"
},
{
"id": "created_date_value",
"type": "basic",
"name": "Span",
"text": "{{row?.created_at}}"
}
]
},
{
"id": "categories_row",
"type": "basic",
"name": "Div",
"if": "{{row.categories && row.categories.length > 0}}",
"props": {
"className": "flex flex-wrap gap-2 items-center"
},
"children": [
{
"id": "category_label",
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.board.index.categories_label"
},
{
"id": "category_tag",
"type": "composite",
"name": "StatusBadge",
"iteration": {
"source": "row.categories",
"item_var": "category"
},
"props": {
"status": "default",
"label": "{{category}}",
"showIcon": false
}
}
]
}
]
}
]
},
"actions": [
{
"event": "onPageChange",
"type": "change",
"handler": "navigate",
"params": {
"path": "/admin/boards",
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
}
}
}
]
}
]
}
]
}
]
},
"modals": [
{
"id": "copy_confirm_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.modals.copy.title",
"size": "small"
},
"children": [
{
"id": "copy_modal_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"id": "copy_board_name",
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-900 dark:text-white font-medium"
},
"text": "$t:sirsoft-board.admin.modals.copy.message|name={{_global.modal_data.name}}"
},
{
"id": "copy_info",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.modals.copy.info"
},
{
"id": "copy_button_group",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "copy_cancel_button",
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:focus:ring-gray-400"
},
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "copy_confirm_modal"
}
]
},
{
"id": "copy_confirm_button",
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 text-sm font-medium text-white bg-blue-600 dark:bg-blue-500 rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-400"
},
"text": "$t:sirsoft-board.admin.board.actions.copy",
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "copy_confirm_modal"
},
{
"type": "click",
"handler": "navigate",
"target": "/admin/boards/create?copy_id={{_global.modal_data.id}}"
}
]
}
]
}
]
}
]
},
{
"id": "delete_confirm_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.modals.delete.title",
"size": "small"
},
"children": [
{
"id": "delete_warning",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"id": "delete_board_name",
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-900 dark:text-white font-semibold text-lg"
},
"text": "$t:sirsoft-board.admin.modals.delete.confirm|name={{_global.modal_data.name}}"
},
{
"id": "delete_posts_info",
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-700 dark:text-gray-300"
},
"text": "{{_global.modal_data.posts_count > 0 ? '$t:sirsoft-board.admin.modals.delete.with_posts_info|posts_count=' + _global.modal_data.posts_count : '$t:sirsoft-board.admin.modals.delete.no_posts_info'}}"
},
{
"id": "delete_main_alert",
"type": "composite",
"name": "Alert",
"props": {
"type": "{{_global.modal_data.posts_count > 0 ? 'error' : 'warning'}}",
"message": "{{_global.modal_data.posts_count > 0 ? '$t:sirsoft-board.admin.modals.delete.force_delete_warning_with_posts' : '$t:sirsoft-board.admin.modals.delete.force_delete_warning_no_posts'}}"
}
},
{
"id": "delete_backup_notice",
"type": "basic",
"name": "P",
"if": "{{_global.modal_data.posts_count > 0}}",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.modals.delete.backup_notice"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [
{
"type": "click",
"handler": "closeModal"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"isDeleting": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/boards/{{_global.modal_data?.id}}",
"params": {
"method": "DELETE",
"body": []
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"isDeleting": false
}
},
{
"handler": "setState",
"params": {
"target": "global",
"forceDeleteBoard": false,
"modal_data": null
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boards",
"sync": true
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "admin_menu"
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.board.messages.deleted"
}
},
{
"handler": "closeModal"
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"isDeleting": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_global.isDeleting}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.isDeleting ? '$t:sirsoft-board.common.deleting' : '$t:sirsoft-board.common.delete'}}"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,494 @@
{
"version": "1.0.0",
"layout_name": "admin_board_post_form",
"permissions": [
"sirsoft-board.boards.read"
],
"extends": "_admin_base",
"meta": {
"title": "{{form_meta?.data?.board?.name}} {{route?.id ? '$t:sirsoft-board.admin.posts.form.edit_title' : (query?.parent_id ? '$t:sirsoft-board.admin.posts.reply.title' : '$t:sirsoft-board.admin.posts.form.create_title')}}",
"description": "{{form_meta?.data?.board?.name}}$t:sirsoft-board.admin.posts.page_description_with_board"
},
"state": {
"isSaving": false,
"hasChanges": false,
"previewMode": false,
"tempKey": null,
"isDataLoaded": false,
"errors": null,
"uploadingFiles": []
},
"data_sources": [
{
"id": "form_data",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts/form-data{{route?.id ? '?post_id=' + route.id : (query?.parent_id ? '?parent_id=' + query.parent_id : '')}}",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"cache": false,
"refetchOnMount": true,
"initLocal": "form",
"loading_strategy": "blocking",
"errorHandling": {
"404": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
},
"403": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
},
"422": {
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
}
},
{
"id": "form_meta",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts/form-meta{{route?.id ? '?post_id=' + route.id : (query?.parent_id ? '?parent_id=' + query.parent_id : '')}}",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"cache": false,
"refetchOnMount": true,
"errorHandling": {
"404": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
},
"403": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
}
}
}
],
"computed": {
"isReadOnly": "{{!!route?.id && !!form_data?.data && form_data?.data?.abilities?.can_write !== true}}"
},
"init_actions": [
{
"comment": "tempKey 생성 (신규/수정 모두 - 취소 시 안전을 위해)",
"handler": "setState",
"params": {
"target": "global",
"_local": {
"tempKey": "{{('temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9))}}"
}
}
}
],
"slots": {
"content": [
{
"id": "post_form_wrapper",
"type": "basic",
"name": "Div",
"blur_until_loaded": {
"enabled": true,
"data_sources": [
"form_data",
"form_meta"
]
},
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900"
},
"children": [
{
"id": "page_header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "{{form_meta?.data?.board?.name}} {{route?.id ? '$t:sirsoft-board.admin.posts.form.edit_title' : (query?.parent_id ? '$t:sirsoft-board.admin.posts.reply.title' : '$t:sirsoft-board.admin.posts.form.create_title')}}",
"description": "{{form_meta?.data?.board?.name}}$t:sirsoft-board.admin.posts.page_description_with_board"
},
"children": [
{
"id": "header_cancel_button",
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "{{route?.id ? '/admin/board/' + route.slug + '/post/' + route.id : '/admin/board/' + route.slug}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.common.cancel"
}
]
},
{
"id": "header_save_button",
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true,
"errors": null
}
},
{
"handler": "emitEvent",
"params": {
"event": "upload:board_attachments"
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/board/' + route.slug + '/posts/' + route.id : '/api/modules/sirsoft-board/admin/board/' + route.slug + '/posts'}}",
"params": {
"method": "{{route?.id ? 'PUT' : 'POST'}}",
"body": "{{Object.assign({}, _local.form, { temp_key: _local.tempKey })}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false,
"errors": null
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{route?.id ? '$t:sirsoft-board.admin.posts.messages.update_success' : '$t:sirsoft-board.admin.posts.messages.create_success'}}"
}
},
{
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{route?.id ?? result?.data?.id}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.posts.messages.save_failed",
"icon": "triangle-exclamation"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{_local.isSaving ? '$t:sirsoft-board.common.saving' : '$t:sirsoft-board.common.save'}}"
}
]
}
]
},
{
"id": "read_only_banner",
"type": "basic",
"name": "Div",
"if": "{{_computed.isReadOnly}}",
"props": {
"className": "mb-4 flex items-center gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "lock",
"className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-board.admin.posts.form.read_only_message"
}
]
},
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-board.admin.form.validation_error"
},
{
"type": "basic",
"name": "P",
"if": "{{typeof _local.errors === 'string'}}",
"props": {
"className": "mt-2 text-sm text-red-700 dark:text-red-300"
},
"text": "{{_local.errors}}"
},
{
"type": "basic",
"name": "Ul",
"if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}",
"props": {
"className": "mt-2 list-disc list-inside space-y-1"
},
"iteration": {
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
"item_var": "message"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
},
"text": "{{message}}"
}
]
}
]
}
]
}
]
},
{
"id": "post_form",
"type": "basic",
"name": "Form",
"props": {
"id": "post_form"
},
"children": [
{
"id": "form_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-6"
},
"children": [
{
"partial": "partials/admin_board_post_form/_parent_post.json"
},
{
"partial": "partials/admin_board_post_form/_form_fields.json"
},
{
"partial": "partials/admin_board_post_form/_attachments.json"
}
]
}
]
},
{
"id": "footer_buttons",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 pt-4 mt-6"
},
"children": [
{
"id": "footer_cancel_button",
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "{{route?.id ? '/admin/board/' + route.slug + '/post/' + route.id : '/admin/board/' + route.slug}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.common.cancel"
}
]
},
{
"id": "footer_save_button",
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true,
"errors": null
}
},
{
"handler": "emitEvent",
"params": {
"event": "upload:board_attachments"
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/board/' + route.slug + '/posts/' + route.id : '/api/modules/sirsoft-board/admin/board/' + route.slug + '/posts'}}",
"params": {
"method": "{{route?.id ? 'PUT' : 'POST'}}",
"body": "{{Object.assign({}, _local.form, { temp_key: _local.tempKey })}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false,
"errors": null
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{route?.id ? '$t:sirsoft-board.admin.posts.messages.update_success' : '$t:sirsoft-board.admin.posts.messages.create_success'}}"
}
},
{
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{route?.id ?? result?.data?.id}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.posts.messages.save_failed",
"icon": "triangle-exclamation"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{_local.isSaving ? '$t:sirsoft-board.common.saving' : '$t:sirsoft-board.common.save'}}"
}
]
}
]
}
]
}
]
}
}
@@ -0,0 +1,365 @@
{
"version": "1.0.0",
"layout_name": "admin_board_reports_detail",
"permissions": [
"sirsoft-board.reports.view"
],
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-board.admin.reports.detail.page_title",
"description": "$t:sirsoft-board.admin.reports.detail.page_description"
},
"state": {
"_local": {
"reporters_page": 1,
"reporters_loading": false
},
"_global": {
"actionReason": "",
"isSaving": false,
"actionType": ""
}
},
"data_sources": [
{
"id": "report_detail",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/reports/{{route.id}}",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"refetchOnMount": true,
"errorHandling": {
"404": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
},
"403": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
}
}
},
{
"id": "reporters_list",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/reports/{{route.id}}/reporters",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"params": {
"page": "{{_local.reporters_page}}",
"per_page": 10
}
}
],
"slots": {
"content": [
{
"id": "report_detail_wrapper",
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 bg-gray-50 dark:bg-gray-900"
},
"responsive": {
"portable": {
"props": {
"className": "p-4 bg-gray-50 dark:bg-gray-900"
}
}
},
"children": [
{
"id": "page_header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "$t:sirsoft-board.admin.reports.detail.page_title",
"description": "$t:sirsoft-board.admin.reports.detail.page_description"
},
"children": [
{
"id": "refresh_button",
"type": "basic",
"name": "Button",
"props": {
"className": "p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded-lg transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/reports/{{route.id}}",
"mergeQuery": false
}
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "arrows-rotate",
"className": "w-5 h-5"
}
}
]
},
{
"id": "back_button",
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/reports"
}
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "arrow-left",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:common.back"
}
]
}
]
},
{
"partial": "partials/admin_board_reports_detail/_alert_status.json"
},
{
"partial": "partials/admin_board_reports_detail/_card_report_info.json"
},
{
"partial": "partials/admin_board_reports_detail/_card_history.json"
}
]
}
]
},
"modals": [
{
"id": "status_action_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "{{_global.actionType === 'pending' ? '$t:sirsoft-board.admin.reports.modals.status_change_pending_title' : _global.actionType === 'review' ? '$t:sirsoft-board.admin.reports.modals.status_change_review_title' : _global.actionType === 'rejected' ? '$t:sirsoft-board.admin.reports.modals.status_change_rejected_title' : _global.actionType === 'deleted' ? '$t:sirsoft-board.admin.reports.modals.status_change_deleted_title' : '$t:sirsoft-board.admin.reports.modals.status_change_suspended_title'}}",
"size": "medium"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "py-2"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-700 dark:text-gray-300 mb-4"
},
"text": "{{_global.actionType === 'pending' ? '$t:sirsoft-board.admin.reports.modals.status_change_pending_confirm_single' : _global.actionType === 'review' ? '$t:sirsoft-board.admin.reports.modals.status_change_review_confirm_single' : _global.actionType === 'rejected' ? '$t:sirsoft-board.admin.reports.modals.status_change_rejected_confirm_single' : _global.actionType === 'deleted' ? '$t:sirsoft-board.admin.reports.modals.status_change_deleted_confirm_single' : '$t:sirsoft-board.admin.reports.modals.status_change_suspended_confirm_single'}}"
},
{
"type": "basic",
"name": "Div",
"if": "{{_global.actionType === 'rejected' && report_detail.data?.target_status === 'blinded'}}",
"props": {
"className": "mb-4 flex items-center gap-2 p-2.5 bg-orange-50 dark:bg-orange-900/20 rounded-md"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "eye",
"className": "w-4 h-4 text-orange-600 dark:text-orange-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-orange-700 dark:text-orange-300"
},
"text": "$t:sirsoft-board.admin.reports.modals.blinded_will_be_public|count=1"
}
]
},
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
},
"text": "$t:sirsoft-board.admin.reports.modals.reason_label"
},
{
"type": "basic",
"name": "Textarea",
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white",
"rows": 4,
"placeholder": "$t:sirsoft-board.admin.reports.modals.reason_placeholder",
"value": "{{_global.actionReason || ''}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"actionReason": "{{$event.target.value}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isSaving}}"
},
"actions": [
{
"type": "click",
"handler": "closeModal"
},
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"actionReason": ""
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed {{_global.actionType === 'pending' ? 'bg-yellow-600 hover:bg-yellow-700' : _global.actionType === 'review' ? 'bg-blue-600 hover:bg-blue-700' : _global.actionType === 'rejected' ? 'bg-purple-600 hover:bg-purple-700' : _global.actionType === 'suspended' ? 'bg-red-600 hover:bg-red-700' : _global.actionType === 'deleted' ? 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600' : 'bg-blue-600 hover:bg-blue-700'}}",
"disabled": "{{_global.isSaving}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"isSaving": true
}
},
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/reports/{{route.id}}/status",
"params": {
"method": "PATCH",
"body": {
"status": "{{_global.actionType === 'pending' ? 'pending' : _global.actionType === 'review' ? 'review' : _global.actionType === 'rejected' ? 'rejected' : _global.actionType === 'deleted' ? 'deleted' : 'suspended'}}",
"process_note": "{{_global.actionReason}}"
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"actionReason": "",
"isSaving": false
}
},
{
"handler": "closeModal"
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.reports.modals.status_change_success"
}
},
{
"handler": "navigate",
"params": {
"path": "/admin/boards/reports/{{route.id}}",
"mergeQuery": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"isSaving": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{_global.isSaving ? $t:common.saving : $t:common.confirm}}"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,648 @@
{
"version": "1.0.0",
"layout_name": "admin_board_settings",
"permissions": [
"sirsoft-board.settings.read"
],
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-board.admin.settings.title",
"description": "$t:sirsoft-board.admin.settings.description"
},
"state": {
"mailTemplateCurrentPage": 1,
"mailTemplateTotalPages": 1,
"mailTemplateTotalItems": 0
},
"initLocal": {
"editingTemplate": null,
"editLang": "ko",
"previewData": null,
"mailTemplateFilter": {
"search": "",
"searchType": "all"
},
"expandedMailTemplateIds": []
},
"data_sources": [
{
"id": "settings",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"initLocal": "form",
"refetchOnMount": true,
"errorHandling": {
"403": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
}
}
},
{
"id": "boards_list",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards?per_page=999&fields=id,title",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": {
"data": []
}
}
},
{
"id": "roles",
"type": "api",
"endpoint": "/api/admin/roles/active",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": []
}
},
{
"id": "board_types",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/board-types",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": []
}
},
{
"id": "boardMailTemplates",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/mail-templates",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"params": {
"page": "{{query.page || 1}}",
"per_page": "{{query.per_page || 20}}",
"search": "{{query.search}}",
"search_type": "{{query.search_type}}"
},
"initLocal": {
"_merge": "deep",
"mailTemplateCurrentPage": "pagination.current_page",
"mailTemplateTotalPages": "pagination.last_page",
"mailTemplateTotalItems": "pagination.total"
},
"fallback": {
"data": {
"data": [],
"pagination": {}
}
}
},
{
"id": "notification_channels",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/notification-channels",
"method": "GET",
"auto_fetch": true,
"auth_required": true
}
],
"init_actions": [
{
"handler": "setState",
"params": {
"target": "_local",
"mailTemplateCurrentPage": "{{+(query.page || 1)}}"
}
}
],
"computed": {
"isReadOnly": "{{!!settings?.data && settings?.data?.abilities?.can_update !== true}}",
"boardOptions": "{{(() => { const boards = boards_list?.data?.data ?? []; return boards.map(b => ({ value: b.id, label: b.name })); })()}}",
"adminPermissions": "{{Object.entries(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(([key]) => key.startsWith('admin.'))}}",
"userPermissions": "{{Object.entries(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(([key]) => !key.startsWith('admin.'))}}",
"roleOptions": "{{(roles?.data?.data ?? []).map(r => ({ value: r.identifier, label: r.name ?? r.identifier }))}}",
"adminPermKeys": "{{Object.entries(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(([key]) => key.startsWith('admin.')).map(([key]) => key)}}",
"userPermKeys": "{{Object.entries(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(([key]) => !key.startsWith('admin.')).map(([key]) => key)}}",
"allAdminChecked": "{{Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => k.startsWith('admin.')).length > 0 && Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => k.startsWith('admin.')).every(k => (_local.bulkApplyFields ?? []).includes(k))}}",
"allUserChecked": "{{Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => !k.startsWith('admin.')).length > 0 && Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => !k.startsWith('admin.')).every(k => (_local.bulkApplyFields ?? []).includes(k))}}"
},
"slots": {
"content": [
{
"id": "settings_content",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.isSaving}}",
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "bg-gray-50 dark:bg-gray-900 min-h-screen"
},
"children": [
{
"id": "sticky_header",
"type": "basic",
"name": "Div",
"props": {
"className": "sticky top-0 z-40 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "header_content",
"type": "basic",
"name": "Div",
"props": {
"className": "px-4 sm:px-6 lg:px-8 pt-4 pb-0"
},
"children": [
{
"id": "header_top_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start justify-between gap-4 mb-3"
},
"children": [
{
"id": "page_header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "$t:sirsoft-board.admin.settings.title",
"description": "$t:sirsoft-board.admin.settings.description"
}
},
{
"id": "header_save_buttons",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') !== 'mail_templates'}}",
"props": {
"className": "flex items-center gap-3 flex-shrink-0 pt-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"if": "{{_local.hasChanges}}",
"props": {
"className": "flex items-center gap-1.5 text-sm text-amber-600 dark:text-amber-400"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-dot",
"size": "xs"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.unsaved_changes"
}
]
},
{
"id": "cancel_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors text-sm"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"id": "save_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || !_local.hasChanges || _local.isSaving}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true,
"errors": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/settings",
"params": {
"method": "PUT",
"body": "{{(function() { const tab = _global.activeBoardSettingsTab || query.tab || 'general'; const form = _local.form || {}; if (tab === 'general') { return { _tab: 'general', display: form.display || {} }; } if (tab === 'report_policy') { return { _tab: 'report_policy', report_policy: form.report_policy || {}, spam_security: form.spam_security || {}, report_permissions: form.report_permissions || {} }; } if (tab === 'spam_security') { return { _tab: 'spam_security', spam_security: form.spam_security || {} }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notification_channels: form.notification_channels || {} }; })()}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "settings"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boards_list"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "roles"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "board_types"
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.settings.save_success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.save_error"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isSaving}}",
"props": {
"name": "spinner",
"size": "sm",
"spin": true
}
},
{
"type": "basic",
"name": "Span",
"if": "{{!_local.isSaving}}",
"text": "$t:common.save"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.isSaving}}",
"text": "$t:common.saving"
}
]
}
]
}
]
},
{
"id": "read_only_banner",
"type": "basic",
"name": "Div",
"if": "{{_computed.isReadOnly}}",
"props": {
"className": "mb-3 flex items-center gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "lock",
"size": "lg",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-board.admin.settings.read_only_message"
}
]
},
{
"id": "tab_navigation",
"type": "composite",
"name": "TabNavigation",
"props": {
"tabs": [
{
"id": "general",
"label": "$t:sirsoft-board.admin.settings.tabs.general"
},
{
"id": "basic_defaults",
"label": "$t:sirsoft-board.admin.settings.tabs.basic_defaults"
},
{
"id": "report_policy",
"label": "$t:sirsoft-board.admin.settings.tabs.report_policy"
},
{
"id": "spam_security",
"label": "$t:sirsoft-board.admin.settings.tabs.spam_security"
},
{
"id": "mail_templates",
"label": "$t:sirsoft-board.admin.settings.tabs.mail_templates"
}
],
"activeTabId": "{{_global.activeBoardSettingsTab || query.tab || 'general'}}",
"variant": "default"
},
"actions": [
{
"event": "onTabChange",
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"errors": null
}
},
{
"handler": "setState",
"params": {
"target": "global",
"activeBoardSettingsTab": "{{$args[0]}}"
}
},
{
"handler": "replaceUrl",
"params": {
"query": {
"tab": "{{$args[0]}}"
}
}
}
]
}
]
}
]
},
{
"id": "sub_tab_navigation",
"type": "composite",
"name": "TabNavigationScroll",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"tabs": [
{
"id": "basic",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.basic"
},
{
"id": "permissions",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.permissions"
},
{
"id": "list",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.list"
},
{
"id": "post",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.post"
},
{
"id": "reply",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.reply"
},
{
"id": "comment",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.comment"
},
{
"id": "attachment",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.attachment"
},
{
"id": "notification",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.notification"
},
{
"id": "bulk_apply",
"label": "$t:sirsoft-board.admin.settings.board_settings_tabs.bulk_apply"
}
],
"className": "bg-white dark:bg-gray-800 flex gap-1 border-b border-gray-200 dark:border-gray-700 px-6 sm:px-8 lg:px-10",
"sectionIdPrefix": "",
"enableScrollSpy": true,
"scrollContainerId": "right_content_area",
"scrollSpyOffset": 180,
"scrollOffset": 180
}
}
]
},
{
"id": "main_content",
"type": "basic",
"name": "Div",
"props": {
"className": "p-4 sm:p-6 lg:p-8"
},
"children": [
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"size": "lg",
"className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-board.admin.settings.validation_error"
},
{
"type": "basic",
"name": "P",
"if": "{{typeof _local.errors === 'string'}}",
"props": {
"className": "mt-2 text-sm text-red-700 dark:text-red-300"
},
"text": "{{_local.errors}}"
},
{
"type": "basic",
"name": "Ul",
"if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}",
"props": {
"className": "mt-2 list-disc list-inside space-y-1"
},
"iteration": {
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
"item_var": "message"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
},
"text": "{{message}}"
}
]
}
]
}
]
}
]
},
{
"partial": "partials/admin_board_settings/_tab_general.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_basic.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_permissions.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_list.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_post.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_reply.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_comment.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_attachment.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_notification.json"
},
{
"partial": "partials/admin_board_settings/_tab_board_settings_bulk_apply.json"
},
{
"partial": "partials/admin_board_settings/_tab_report_policy.json"
},
{
"partial": "partials/admin_board_settings/_tab_spam_security.json"
},
{
"partial": "partials/admin_board_settings/_tab_mail_templates.json"
}
]
}
]
}
]
},
"modals": [
{
"partial": "partials/admin_board_settings/_bulk_apply_modal.json"
},
{
"partial": "partials/admin_board_form/_board_type_manage_modal.json"
},
{
"partial": "partials/admin_board_settings/_modal_mail_template_edit.json"
},
{
"partial": "partials/admin_board_settings/_modal_mail_template_preview.json"
}
]
}
@@ -0,0 +1,746 @@
{
"meta": {
"is_partial": true,
"description": "Board Type Management Modal - CRUD for board types"
},
"id": "board_type_manage_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.board_types.modal_title",
"width": "640px",
"closeOnBackdropClick": false
},
"children": [
{
"id": "bt_modal_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"classMap": {
"key": "{{$parent._computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "bt_usage_guide",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg px-3 py-2.5"
},
"children": [
{
"id": "bt_usage_guide_icon",
"type": "basic",
"name": "Icon",
"props": {
"name": "fa-solid fa-circle-info",
"className": "mt-0.5 shrink-0"
}
},
{
"id": "bt_usage_guide_text",
"type": "basic",
"name": "Span",
"props": {
"className": "leading-relaxed"
},
"text": "$t:sirsoft-board.admin.board_types.usage_guide"
}
]
},
{
"id": "bt_list_section",
"type": "basic",
"name": "Div",
"props": {
"className": "max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"id": "bt_table",
"type": "basic",
"name": "Table",
"props": {
"className": "min-w-full divide-y divide-gray-200 dark:divide-gray-700"
},
"children": [
{
"id": "bt_thead",
"type": "basic",
"name": "Thead",
"children": [
{
"id": "bt_thead_row",
"type": "basic",
"name": "Tr",
"children": [
{
"id": "bt_th_slug",
"type": "basic",
"name": "Th",
"props": {
"className": "px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
},
"text": "$t:sirsoft-board.admin.board_types.slug"
},
{
"id": "bt_th_name",
"type": "basic",
"name": "Th",
"props": {
"className": "px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
},
"text": "$t:sirsoft-board.admin.board_types.name"
},
{
"id": "bt_th_actions",
"type": "basic",
"name": "Th",
"props": {
"className": "px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24"
},
"text": "$t:sirsoft-board.admin.board_types.actions"
}
]
}
]
},
{
"id": "bt_tbody",
"type": "basic",
"name": "Tbody",
"props": {
"className": "bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"
},
"children": [
{
"id": "bt_empty_row",
"type": "basic",
"name": "Tr",
"if": "{{!_global.boardTypesList || _global.boardTypesList.length === 0}}",
"children": [
{
"id": "bt_empty_cell",
"type": "basic",
"name": "Td",
"props": {
"colSpan": 3,
"className": "px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.board_types.empty"
}
]
},
{
"id": "bt_row",
"type": "basic",
"name": "Tr",
"if": "{{_global.boardTypesList && _global.boardTypesList.length > 0}}",
"iteration": {
"source": "{{_global.boardTypesList ?? []}}",
"item_var": "btItem",
"index_var": "btIdx"
},
"props": {
"className": "hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50 transition-colors duration-150"
},
"classMap": {
"key": "{{_global.btEditingItem?.id === btItem.id ? 'true' : 'false'}}",
"variants": {
"true": "bg-blue-50 dark:bg-blue-900/20",
"false": ""
}
},
"children": [
{
"id": "bt_td_slug",
"type": "basic",
"name": "Td",
"props": {
"className": "px-3 py-2 text-sm text-gray-900 dark:text-gray-100 font-mono"
},
"text": "{{btItem.slug}}"
},
{
"id": "bt_td_name",
"type": "basic",
"name": "Td",
"props": {
"className": "px-3 py-2 text-sm text-gray-900 dark:text-gray-100"
},
"text": "{{btItem.name[_global.locale ?? 'ko'] ?? btItem.name.ko ?? btItem.slug}}"
},
{
"id": "bt_td_actions",
"type": "basic",
"name": "Td",
"props": {
"className": "px-3 py-2 text-center"
},
"children": [
{
"id": "bt_action_btns",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-center gap-1"
},
"children": [
{
"id": "bt_edit_btn",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "p-1 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-400",
"title": "$t:common.edit",
"disabled": "{{_computed.isReadOnly || _global.btSaving}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"btEditingItem": "{{btItem}}",
"btFormMode": "edit",
"btFormSlug": "{{btItem.slug}}",
"btFormName": "{{typeof btItem.name === 'object' ? btItem.name : {ko: btItem.name ?? '', en: ''}}}",
"btFormErrors": null
}
}
],
"children": [
{
"id": "bt_edit_icon",
"type": "basic",
"name": "Icon",
"props": {
"name": "pencil",
"size": "sm"
}
}
]
},
{
"id": "bt_delete_btn",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "p-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:text-red-400 dark:hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed",
"title": "$t:common.delete",
"disabled": "{{_global.btDeletingId != null || _global.btSaving}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"btDeletingId": "{{btItem.id}}"
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board-types/{{btItem.id}}",
"params": {
"method": "DELETE"
},
"confirm": "$t:sirsoft-board.admin.board_types.delete_confirm",
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"btDeletingId": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board-types",
"params": {
"method": "GET"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"boardTypesList": "{{response.data}}"
}
}
]
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boards"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "board_types"
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.board_types.delete_success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"btDeletingId": null
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.errors ? Object.values(error.errors).flat()[0] : error.message}}"
}
}
]
}
]
}
],
"children": [
{
"id": "bt_delete_icon",
"type": "basic",
"name": "Icon",
"if": "{{_global.btDeletingId !== btItem.id}}",
"props": {
"name": "trash",
"size": "sm"
}
},
{
"id": "bt_delete_spinner",
"type": "basic",
"name": "Icon",
"if": "{{_global.btDeletingId === btItem.id}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
}
],
"if": "{{!_computed.isReadOnly}}"
}
]
}
]
}
]
}
]
}
]
}
]
},
{
"id": "bt_form_section",
"type": "basic",
"name": "Div",
"props": {
"className": "border-t border-gray-200 dark:border-gray-700 pt-4 rounded-lg transition-all duration-200"
},
"classMap": {
"key": "{{_global.btFormMode}}",
"variants": {
"edit": "ring-2 ring-blue-300 dark:ring-blue-600 bg-blue-50/30 dark:bg-blue-900/10 p-4 -mx-1",
"add": ""
}
},
"children": [
{
"id": "bt_form_header",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-3"
},
"children": [
{
"id": "bt_form_title",
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "{{_global.btFormMode === 'edit' ? '$t:sirsoft-board.admin.board_types.edit_title' : '$t:sirsoft-board.admin.board_types.add_title'}}"
},
{
"id": "bt_form_cancel_btn",
"type": "basic",
"name": "Button",
"if": "{{_global.btFormMode === 'edit'}}",
"props": {
"type": "button",
"className": "px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600"
},
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"btFormMode": "add",
"btEditingItem": null,
"btFormSlug": "",
"btFormName": {
"ko": "",
"en": ""
},
"btFormErrors": null
}
}
]
}
]
},
{
"id": "bt_form_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"id": "bt_field_slug",
"type": "basic",
"name": "Div",
"children": [
{
"id": "bt_label_slug",
"type": "basic",
"name": "Label",
"props": {
"className": "block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"
},
"text": "$t:sirsoft-board.admin.board_types.slug"
},
{
"id": "bt_input_slug",
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"value": "{{_global.btFormSlug ?? ''}}",
"placeholder": "basic",
"className": "w-full px-2 py-1.5 text-sm border rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500",
"disabled": "{{_computed.isReadOnly || _global.btFormMode === 'edit'}}"
},
"classMap": {
"key": "{{_global.btFormErrors?.slug ? 'error' : 'normal'}}",
"variants": {
"error": "border-red-500 dark:border-red-400",
"normal": "border-gray-300 dark:border-gray-600"
}
},
"actions": [
{
"type": "input",
"handler": "setState",
"params": {
"target": "global",
"btFormSlug": "{{$event.target.value}}",
"btFormErrors": "{{(() => { const e = {...(_global.btFormErrors ?? {})}; delete e.slug; return Object.keys(e).length ? e : null; })()}}"
}
}
]
},
{
"id": "bt_slug_hint",
"type": "basic",
"name": "P",
"if": "{{_global.btFormMode !== 'edit' && !_global.btFormErrors?.slug}}",
"props": {
"className": "mt-1 text-xs text-gray-400 dark:text-gray-500"
},
"text": "$t:sirsoft-board.admin.board_types.slug_hint"
},
{
"id": "bt_slug_error",
"type": "basic",
"name": "P",
"if": "{{_global.btFormErrors?.slug}}",
"props": {
"className": "mt-1 text-xs text-red-500 dark:text-red-400"
},
"text": "{{_global.btFormErrors?.slug?.[0] ?? ''}}"
}
]
},
{
"id": "bt_field_name",
"type": "basic",
"name": "Div",
"children": [
{
"id": "bt_label_name",
"type": "basic",
"name": "Label",
"props": {
"className": "block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"
},
"text": "$t:sirsoft-board.admin.board_types.name"
},
{
"id": "bt_input_name",
"type": "composite",
"name": "MultilingualInput",
"props": {
"value": "{{_global.btFormName ?? {ko: '', en: ''}}}",
"layout": "tabs",
"name": "bt_form_name",
"placeholder": "$t:sirsoft-board.admin.board_types.name_placeholder",
"maxLength": 100,
"required": true,
"error": "{{_global.btFormErrors?.['name.ko']?.[0] ?? ''}}",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "global",
"btFormName": "{{$args[0]?.target?.value ?? _global.btFormName}}",
"btFormErrors": "{{(() => { const e = {...(_global.btFormErrors ?? {})}; delete e['name.ko']; delete e['name']; return Object.keys(e).length ? e : null; })()}}"
}
}
]
},
{
"id": "bt_name_error",
"type": "basic",
"name": "P",
"if": "{{_global.btFormErrors?.['name.ko'] || _global.btFormErrors?.['name']}}",
"props": {
"className": "mt-1 text-xs text-red-500 dark:text-red-400"
},
"text": "{{_global.btFormErrors?.['name.ko']?.[0] ?? _global.btFormErrors?.['name']?.[0] ?? ''}}"
}
]
}
]
},
{
"id": "bt_form_submit",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end mt-3"
},
"children": [
{
"id": "bt_submit_btn",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_computed.isReadOnly || _global.btSaving || !_global.btFormSlug || !_global.btFormName?.ko}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"btSaving": true,
"btFormErrors": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "{{_global.btFormMode === 'edit' ? '/api/modules/sirsoft-board/admin/board-types/' + _global.btEditingItem.id : '/api/modules/sirsoft-board/admin/board-types'}}",
"params": {
"method": "{{_global.btFormMode === 'edit' ? 'PUT' : 'POST'}}",
"body": {
"slug": "{{_global.btFormSlug}}",
"name": "{{_global.btFormName ?? {ko: '', en: ''}}}"
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"btSaving": false,
"btFormMode": "add",
"btEditingItem": null,
"btFormSlug": "",
"btFormName": {
"ko": "",
"en": ""
},
"btFormErrors": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board-types",
"params": {
"method": "GET"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"boardTypesList": "{{response.data}}"
}
}
]
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boards"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "board_types"
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{_global.btFormMode === 'edit' ? '$t:sirsoft-board.admin.board_types.update_success' : '$t:sirsoft-board.admin.board_types.create_success'}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"btSaving": false,
"btFormErrors": "{{error.errors ?? null}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.errors ? Object.values(error.errors).flat()[0] : error.message}}"
}
}
]
}
]
}
],
"children": [
{
"id": "bt_submit_spinner",
"type": "basic",
"name": "Icon",
"if": "{{_global.btSaving}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"id": "bt_submit_text",
"type": "basic",
"name": "Span",
"text": "{{_global.btFormMode === 'edit' ? (_global.btSaving ? '$t:common.saving' : '$t:common.save') : (_global.btSaving ? '$t:sirsoft-board.admin.board_types.adding' : '$t:sirsoft-board.admin.board_types.add')}}"
}
]
}
]
}
]
}
]
},
{
"id": "bt_modal_footer",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end mt-6"
},
"children": [
{
"id": "bt_modal_close_btn",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"btFormMode": "add",
"btEditingItem": null,
"btFormSlug": "",
"btFormName": {
"ko": "",
"en": ""
},
"btFormErrors": null
}
},
{
"handler": "closeModal"
}
]
}
],
"children": [
{
"id": "bt_close_text",
"type": "basic",
"name": "Span",
"text": "$t:common.close"
}
]
}
]
}
]
}
@@ -0,0 +1,713 @@
{
"meta": {
"is_partial": true,
"description": "Board Form - Basic Tab Content"
},
"id": "tab_content_basic",
"type": "basic",
"name": "Div",
"props": {
"id": "tab_content_basic"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_basic_card",
"type": "layout",
"name": "SectionLayout",
"props": {
"background": "white",
"border": true,
"shadow": "md",
"rounded": true,
"padding": "xl",
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"id": "section_basic_settings",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-6 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.form.tabs.basic"
},
{
"id": "field_is_active",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between items-start mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.fields.is_active"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.descriptions.is_active"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "is_active",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
},
{
"id": "field_slug",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6"
},
"children": [
{
"id": "field_slug_label",
"type": "basic",
"name": "Label",
"props": {
"required": true,
"className": "block text-sm font-medium text-gray-900 dark:text-white mb-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.slug"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-red-500 dark:text-red-400 ml-1"
},
"text": "*"
}
]
},
{
"id": "field_slug_input",
"type": "basic",
"name": "Input",
"props": {
"name": "slug",
"type": "text",
"placeholder": "$t:sirsoft-board.admin.form.placeholders.slug",
"required": true,
"disabled": "{{!!route?.id || _computed.isReadOnly}}",
"className": "w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 {{!!route?.id || _computed.isReadOnly ? 'border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed' : 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.slug}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{_local.errors?.slug?.[0] ?? ''}}"
},
{
"id": "field_slug_description",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.descriptions.slug"
},
{
"id": "field_menu_guide",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 p-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 mb-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "w-4 h-4 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.form.menu.manual_guide"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 text-xs text-gray-600 dark:text-gray-400"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-medium text-gray-700 dark:text-gray-300"
},
"text": "•"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-medium"
},
"text": "$t:sirsoft-board.admin.form.menu.manual_admin_label:"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "ml-2 px-2 py-0.5 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded text-gray-800 dark:text-gray-200 font-mono"
},
"text": "/admin/board/{{_local.form?.slug ?? 'notice'}}"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{!!route?.id}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-blue-500 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_computed.isReadOnly}}"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "plus",
"className": "w-3 h-3"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.menu.add_to_admin_menu"
}
],
"actions": [
{
"type": "click",
"handler": "apiCall",
"target": "/api/modules/sirsoft-board/admin/boards/{{route?.id}}/add-to-menu",
"params": {
"method": "POST"
},
"auth_required": true,
"onSuccess": [
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{result.message}}"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "admin_menu",
"sync": true
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-medium text-gray-700 dark:text-gray-300"
},
"text": "•"
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-medium"
},
"text": "$t:sirsoft-board.admin.form.menu.manual_user_label:"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "ml-2 px-2 py-0.5 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded text-gray-800 dark:text-gray-200 font-mono"
},
"text": "/board/{{_local.form?.slug ?? 'notice'}}"
}
]
}
]
}
]
}
]
}
]
},
{
"id": "field_name",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6"
},
"children": [
{
"id": "field_name_label",
"type": "basic",
"name": "Label",
"props": {
"required": true,
"className": "block text-sm font-medium text-gray-900 dark:text-white mb-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.name"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-red-500 dark:text-red-400 ml-1"
},
"text": "*"
}
]
},
{
"id": "field_name_input",
"type": "composite",
"name": "MultilingualInput",
"props": {
"name": "name",
"layout": "tabs",
"placeholder": "$t:sirsoft-board.admin.form.placeholders.name",
"maxLength": 100,
"required": true,
"disabled": "{{_computed.isReadOnly}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.name}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{_local.errors?.name?.[0] ?? ''}}"
}
]
},
{
"id": "field_description",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6"
},
"children": [
{
"id": "field_description_label",
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white mb-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.description"
}
]
},
{
"id": "field_description_input",
"type": "composite",
"name": "MultilingualInput",
"props": {
"name": "description",
"layout": "tabs",
"inputType": "textarea",
"placeholder": "$t:sirsoft-board.admin.form.placeholders.description",
"rows": 3,
"disabled": "{{_computed.isReadOnly}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.description}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{_local.errors?.description?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.descriptions.description"
}
]
},
{
"id": "field_type",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6"
},
"children": [
{
"id": "field_type_label",
"type": "basic",
"name": "Label",
"props": {
"required": true,
"className": "block text-sm font-medium text-gray-900 dark:text-white mb-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.type"
}
]
},
{
"id": "field_type_select",
"type": "composite",
"name": "Select",
"props": {
"name": "type",
"options": "{{boards?.data?.board_types?.map(t => ({value: t.slug, label: (t.name[_global.locale ?? 'ko'] ?? t.name.ko ?? t.slug) + ' (' + t.slug + ')'})) ?? []}}",
"placeholder": "$t:sirsoft-board.admin.form.placeholders.select_type",
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.descriptions.type"
}
]
},
{
"id": "field_categories",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6 pb-6"
},
"children": [
{
"id": "field_categories_label",
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.categories"
}
]
},
{
"id": "field_categories_description",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-2"
},
"text": "$t:sirsoft-board.admin.form.descriptions.categories"
},
{
"id": "field_categories_input",
"type": "composite",
"name": "TagInput",
"props": {
"name": "categories",
"options": "{{_local.form?.category_options ?? []}}",
"creatable": true,
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "before_remove",
"handler": "check_category_usage",
"on_blocked": {
"handler": "setState",
"target": "_local.showCategoryRemoveModal",
"value": true
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.categories}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{_local.errors?.categories?.[0] ?? ''}}"
},
{
"id": "field_categories_notice",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400 mt-2"
},
"text": "$t:sirsoft-board.admin.form.descriptions.categories_notice"
}
]
},
{
"id": "field_secret_mode",
"type": "basic",
"name": "Div",
"props": {
"className": "flex pt-4 justify-between items-center"
},
"children": [
{
"id": "field_secret_mode_label",
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.secret_mode"
}
]
},
{
"id": "field_secret_mode_select",
"type": "composite",
"name": "Select",
"props": {
"name": "secret_mode",
"disabled": "{{_computed.isReadOnly}}",
"options": [
{
"value": "disabled",
"label": "$t:sirsoft-board.admin.form.options.secret_mode.disabled"
},
{
"value": "enabled",
"label": "$t:sirsoft-board.admin.form.options.secret_mode.enabled"
},
{
"value": "always",
"label": "$t:sirsoft-board.admin.form.options.secret_mode.always"
}
],
"className": "w-48"
}
}
]
},
{
"id": "field_show_view_count",
"type": "basic",
"name": "Div",
"props": {
"className": "flex pt-4 justify-between items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.fields.show_view_count"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.descriptions.show_view_count"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "show_view_count",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
},
{
"id": "field_use_report",
"type": "basic",
"name": "Div",
"props": {
"className": "flex pt-4 justify-between items-start"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.fields.use_report"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.descriptions.use_report"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "use_report",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
}
]
}
]
}
@@ -0,0 +1,273 @@
{
"meta": {
"is_partial": true,
"description": "Board Form - List Tab Content"
},
"id": "tab_content_list",
"type": "basic",
"name": "Div",
"props": {
"id": "tab_content_list"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_list_card",
"type": "layout",
"name": "SectionLayout",
"props": {
"background": "white",
"border": true,
"shadow": "md",
"rounded": true,
"padding": "xl",
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"id": "section_list",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-6 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.form.tabs.list"
},
{
"id": "list_sort_group",
"type": "basic",
"name": "Div",
"props": {
"className": "grid mb-6 pb-8 grid-cols-2 gap-4"
},
"children": [
{
"id": "order_by_field",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"id": "order_by_label",
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.order_by.label"
}
]
},
{
"id": "order_by_select",
"type": "composite",
"name": "Select",
"props": {
"name": "order_by",
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full",
"options": [
{
"value": "created_at",
"label": "$t:sirsoft-board.admin.form.fields.order_by.options.created_at"
},
{
"value": "view_count",
"label": "$t:sirsoft-board.admin.form.fields.order_by.options.view_count"
},
{
"value": "title",
"label": "$t:sirsoft-board.admin.form.fields.order_by.options.title"
},
{
"value": "author",
"label": "$t:sirsoft-board.admin.form.fields.order_by.options.author"
}
]
}
}
]
},
{
"id": "order_direction_field",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"id": "order_direction_label",
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.order_direction.label"
}
]
},
{
"id": "order_direction_select",
"type": "composite",
"name": "Select",
"props": {
"name": "order_direction",
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full",
"options": [
{
"value": "DESC",
"label": "$t:sirsoft-board.admin.form.fields.order_direction.options.desc"
},
{
"value": "ASC",
"label": "$t:sirsoft-board.admin.form.fields.order_direction.options.asc"
}
]
}
}
]
}
]
},
{
"id": "list_per_page_group",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-2 gap-4"
},
"children": [
{
"id": "per_page_field",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"id": "per_page_label",
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.per_page.label"
}
]
},
{
"id": "per_page_input",
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "per_page",
"required": true,
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
}
},
{
"id": "per_page_hint",
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.form.fields.per_page.hint|min={{board_config?.data?.limits?.per_page_min ?? 5}}|max={{board_config?.data?.limits?.per_page_max ?? 100}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.per_page}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{_local.errors?.per_page?.[0] ?? ''}}"
}
]
},
{
"id": "per_page_mobile_field",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"id": "per_page_mobile_label",
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.fields.per_page_mobile.label"
}
]
},
{
"id": "per_page_mobile_input",
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "per_page_mobile",
"required": true,
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
}
},
{
"id": "per_page_mobile_hint",
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.form.fields.per_page_mobile.hint|min={{board_config?.data?.limits?.per_page_min ?? 5}}|max={{board_config?.data?.limits?.per_page_max ?? 100}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.per_page_mobile}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{_local.errors?.per_page_mobile?.[0] ?? ''}}"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,214 @@
{
"meta": {
"is_partial": true,
"description": "Board Form - Notification Tab Content"
},
"id": "tab_content_notification",
"type": "basic",
"name": "Div",
"props": {
"id": "tab_content_notification"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_notification_card",
"type": "layout",
"name": "SectionLayout",
"props": {
"background": "white",
"border": true,
"shadow": "md",
"rounded": true,
"padding": "xl",
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"id": "section_notification",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.form.tabs.notification"
},
{
"id": "notification_settings_section",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-0"
},
"children": [
{
"comment": "작성자 댓글 알림",
"id": "notify_author_section",
"type": "basic",
"name": "Div",
"props": {
"className": "border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between items-start py-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_author.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_author.description"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "notify_author",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
},
{
"id": "notify_author_channels_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3 pb-4 ml-6"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400 flex-shrink-0"
},
"text": "$t:sirsoft-board.admin.form.fields.notification_channels.channels_label"
},
{
"type": "composite",
"name": "TagInput",
"props": {
"name": "notify_author_channels",
"options": "{{notification_channels?.data ?? []}}",
"className": "w-64 flex-shrink-0"
}
}
]
}
]
},
{
"comment": "신규 게시글 관리자 알림",
"id": "notify_admin_on_post_section",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between items-start py-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_admin_on_post.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_admin_on_post.description"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "notify_admin_on_post",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
},
{
"id": "notify_admin_channels_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3 pb-4 ml-6"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400 flex-shrink-0"
},
"text": "$t:sirsoft-board.admin.form.fields.notification_channels.channels_label"
},
{
"type": "composite",
"name": "TagInput",
"props": {
"name": "notify_admin_on_post_channels",
"options": "{{notification_channels?.data ?? []}}",
"className": "w-64 flex-shrink-0"
}
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,725 @@
{
"meta": {
"is_partial": true,
"description": "Board Form - Permissions Tab Content"
},
"id": "tab_content_permissions",
"type": "basic",
"name": "Div",
"props": {
"id": "tab_content_permissions"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_permissions_card",
"type": "layout",
"name": "SectionLayout",
"props": {
"background": "white",
"border": true,
"shadow": "md",
"rounded": true,
"padding": "xl",
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"id": "section_permissions_header",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-6 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.form.tabs.permissions"
},
{
"id": "permissions_section_managers",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-8"
},
"children": [
{
"id": "permissions_managers_label",
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.permissions.managers_title"
}
]
},
{
"id": "permissions_managers_description",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
},
"text": "$t:sirsoft-board.admin.form.permissions.managers_description"
},
{
"id": "permissions_managers_taginput",
"type": "composite",
"name": "TagInput",
"props": {
"value": "{{_local.form?.board_manager_ids ?? []}}",
"options": "{{ [...(_local.form?.board_managers ?? []), ...(_local.managerSearchResults ?? [])].filter((u, i, a) => a.findIndex(x => x.uuid === u.uuid) === i).map(u => ({ value: u.uuid, label: u.name + ' (' + u.email + ')' })) }}",
"placeholder": "$t:sirsoft-board.admin.form.permissions.search_placeholder",
"noOptionsMessage": "$t:sirsoft-board.admin.form.permissions.no_search_results",
"className": "w-full",
"defaultVariant": "blue",
"isSearchable": true,
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"event": "onInputChange",
"handler": "apiCall",
"debounce": 300,
"auth_required": true,
"target": "/api/admin/users",
"params": {
"method": "GET",
"body": {
"filters[0][field]": "all",
"filters[0][value]": "{{$event.target.value}}",
"filters[0][operator]": "like",
"per_page": 20
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"managerSearchResults": "{{result.data?.data ?? []}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"managerSearchResults": []
}
}
]
},
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"hasChanges": true,
"managerSearchResults": [],
"form": {
"board_manager_ids": "{{$event.target.value ?? []}}",
"board_managers": "{{ [...(_local.form?.board_managers ?? []), ...(_local.managerSearchResults ?? [])].filter(u => ($event.target.value ?? []).includes(u.uuid)).filter((u, i, a) => a.findIndex(x => x.uuid === u.uuid) === i) }}"
}
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{ !!_local.errors?.board_manager_ids }}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{ _local.errors?.board_manager_ids?.[0] }}"
}
]
},
{
"id": "permissions_section_steps",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-8"
},
"children": [
{
"id": "permissions_steps_label",
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.permissions.steps_title"
}
]
},
{
"id": "permissions_steps_description",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
},
"text": "$t:sirsoft-board.admin.form.permissions.steps_description"
},
{
"id": "permissions_steps_taginput",
"type": "composite",
"name": "TagInput",
"props": {
"value": "{{_local.form?.board_step_ids ?? []}}",
"options": "{{ [...(_local.form?.board_steps ?? []), ...(_local.stepSearchResults ?? [])].filter((u, i, a) => a.findIndex(x => x.uuid === u.uuid) === i).map(u => ({ value: u.uuid, label: u.name + ' (' + u.email + ')' })) }}",
"placeholder": "$t:sirsoft-board.admin.form.permissions.search_placeholder",
"noOptionsMessage": "$t:sirsoft-board.admin.form.permissions.no_search_results",
"className": "w-full",
"defaultVariant": "green",
"isSearchable": true,
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"event": "onInputChange",
"handler": "apiCall",
"debounce": 300,
"auth_required": true,
"target": "/api/admin/users",
"params": {
"method": "GET",
"body": {
"filters[0][field]": "all",
"filters[0][value]": "{{$event.target.value}}",
"filters[0][operator]": "like",
"per_page": 20
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"stepSearchResults": "{{result.data?.data ?? []}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"stepSearchResults": []
}
}
]
},
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"hasChanges": true,
"stepSearchResults": [],
"form": {
"board_step_ids": "{{$event.target.value ?? []}}",
"board_steps": "{{ [...(_local.form?.board_steps ?? []), ...(_local.stepSearchResults ?? [])].filter(u => ($event.target.value ?? []).includes(u.uuid)).filter((u, i, a) => a.findIndex(x => x.uuid === u.uuid) === i) }}"
}
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{ !!_local.errors?.board_step_ids }}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{ _local.errors?.board_step_ids?.[0] }}"
}
]
},
{
"id": "permissions_role_access_section",
"type": "basic",
"name": "Div",
"props": {
"className": "pt-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "mb-4"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.permissions.settings_title"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.permissions.settings_description"
},
{
"type": "basic",
"name": "P",
"if": "{{ !!route?.id }}",
"props": {
"className": "text-xs text-blue-600 dark:text-blue-400 mt-2 flex items-center gap-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "info-circle",
"size": "sm",
"className": "text-blue-500 dark:text-blue-400"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.permissions.auto_role_notice"
}
]
}
]
},
{
"id": "permissions_create_mode_notice",
"type": "basic",
"name": "Div",
"if": "{{ !route?.id }}",
"props": {
"className": "mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg flex items-center gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "info-circle",
"size": "lg",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-blue-700 dark:text-blue-300"
},
"text": "$t:sirsoft-board.admin.form.permissions.create_mode_permissions_notice"
}
]
},
{
"id": "permissions_error_alert",
"type": "basic",
"name": "Div",
"if": "{{ !!route?.id && (_local.errors?.permissions || Object.keys(_local.errors ?? {}).some(k => k.startsWith('permissions.'))) }}",
"props": {
"className": "mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "exclamation-circle",
"size": "lg",
"className": "text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-board.admin.form.permissions.error_title"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-red-700 dark:text-red-300 mt-1"
},
"text": "{{ _local.errors?.permissions?.[0] ?? '$t:sirsoft-board.admin.form.permissions.error_description' }}"
}
]
}
]
}
]
},
{
"id": "permissions_section_admin",
"type": "basic",
"name": "Div",
"if": "{{ !!route?.id }}",
"props": {
"className": "mb-4"
},
"children": [
{
"type": "composite",
"name": "Accordion",
"props": {
"defaultOpen": true,
"className": "border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"slot": "trigger",
"className": "flex items-center gap-2 w-full px-2 py-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "shield",
"size": "lg",
"className": "text-purple-500 dark:text-purple-400"
}
},
{
"type": "basic",
"name": "H4",
"props": {
"className": "font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.permissions.admin_permissions_title"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chevron-down",
"size": "sm",
"className": "text-gray-400 dark:text-gray-500 ml-auto"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"slot": "content",
"className": "px-4 py-4"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
},
"text": "$t:sirsoft-board.admin.form.permissions.admin_permissions_description"
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.form?.permissions !== undefined}}",
"props": {
"className": "space-y-2 grid grid-cols-1 md:grid-cols-2 md:gap-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "Object.entries(_local.form?.permissions ?? {}).filter(([key]) => key.startsWith('admin')).map(([key, val]) => ({ ...val, _key: key }))",
"item_var": "perm"
},
"children": [
{
"id": "admin_perm_row",
"type": "basic",
"name": "Div",
"key": "{{perm?._key}}",
"props": {
"className": "{{ _local.errors?.['permissions.' + perm?._key + '.roles'] ? 'bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2 border border-red-300 dark:border-red-700' : 'bg-gray-50 dark:bg-gray-700 rounded-lg px-3 py-2 border border-gray-200 dark:border-gray-600' }}"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.name}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.name}}_desc"
}
]
},
{
"type": "composite",
"name": "TagInput",
"props": {
"name": "permissions.{{perm?._key}}.roles",
"value": "{{ _local.form?.permissions?.[perm?._key]?.roles ?? [] }}",
"options": "{{ (roles?.data?.data ?? []).map(r => ({ value: r?.identifier, label: r?.name })) }}",
"placeholder": "$t:sirsoft-board.admin.form.permissions.select_roles",
"className": "w-full",
"defaultVariant": "purple",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"hasChanges": "{{ route?.id ? true : _local.hasChanges }}",
"form": {
"permissions": "{{ Object.assign({}, _local.form?.permissions ?? {}, { [perm?._key]: { ..._local.form?.permissions?.[perm?._key], roles: $event.target.value } }) }}"
}
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{ _local.errors?.['permissions.' + perm?._key + '.roles'] }}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{ _local.errors?.['permissions.' + perm?._key + '.roles']?.[0] ?? '$t:sirsoft-board.admin.form.permissions.error_roles_required' }}"
}
]
}
]
}
]
}
]
}
]
}
]
},
{
"id": "permissions_section_user",
"type": "basic",
"name": "Div",
"if": "{{ !!route?.id }}",
"props": {
"className": "mt-4"
},
"children": [
{
"type": "composite",
"name": "Accordion",
"props": {
"defaultOpen": true,
"className": "border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"slot": "trigger",
"className": "flex items-center gap-2 w-full px-2 py-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "users",
"size": "lg",
"className": "text-blue-500 dark:text-blue-400"
}
},
{
"type": "basic",
"name": "H4",
"props": {
"className": "font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.permissions.user_permissions_title"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chevron-down",
"size": "sm",
"className": "text-gray-400 dark:text-gray-500 ml-auto"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"slot": "content",
"className": "px-4 py-4"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
},
"text": "$t:sirsoft-board.admin.form.permissions.user_permissions_description"
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.form?.permissions !== undefined}}",
"props": {
"className": "space-y-2 grid grid-cols-1 md:grid-cols-2 md:gap-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "Object.entries(_local.form?.permissions ?? {}).filter(([key]) => !key.startsWith('admin')).map(([key, val]) => ({ ...val, _key: key }))",
"item_var": "perm"
},
"children": [
{
"id": "user_perm_row",
"type": "basic",
"name": "Div",
"key": "{{perm?._key}}",
"props": {
"className": "{{ _local.errors?.['permissions.' + perm?._key + '.roles'] ? 'bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2 border border-red-300 dark:border-red-700' : 'bg-gray-50 dark:bg-gray-700 rounded-lg px-3 py-2 border border-gray-200 dark:border-gray-600' }}"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.name}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.name}}_desc"
}
]
},
{
"type": "composite",
"name": "TagInput",
"props": {
"name": "permissions.{{perm?._key}}.roles",
"value": "{{ _local.form?.permissions?.[perm?._key]?.roles ?? [] }}",
"options": "{{ (roles?.data?.data ?? []).map(r => ({ value: r?.identifier, label: r?.name })) }}",
"placeholder": "$t:sirsoft-board.admin.form.permissions.select_roles",
"className": "w-full",
"defaultVariant": "blue",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"hasChanges": "{{ route?.id ? true : _local.hasChanges }}",
"form": {
"permissions": "{{ Object.assign({}, _local.form?.permissions ?? {}, { [perm?._key]: { ..._local.form?.permissions?.[perm?._key], roles: $event.target.value } }) }}"
}
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{ _local.errors?.['permissions.' + perm?._key + '.roles'] }}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{ _local.errors?.['permissions.' + perm?._key + '.roles']?.[0] ?? '$t:sirsoft-board.admin.form.permissions.error_roles_required' }}"
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,732 @@
{
"meta": {
"is_partial": true,
"description": "Admin Board Post Detail - Single Comment Component (Enhanced Design)"
},
"type": "basic",
"name": "Div",
"children": [
{
"id": "comment_header",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start justify-between gap-1"
},
"children": [
{
"id": "comment_left_section",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 flex-1 min-w-0"
},
"children": [
{
"id": "depth_indicator",
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-400 dark:text-gray-500 flex-shrink-0 text-sm"
},
"text": "↳",
"if": "{{comment.depth > 0}}"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-1 flex-1 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"id": "author_name_menu",
"type": "composite",
"name": "ActionMenu",
"if": "{{!comment?.author?.is_guest}}",
"props": {
"triggerLabel": "{{comment?.author?.name}}",
"triggerIconName": "",
"triggerClassName": "font-semibold text-gray-900 dark:text-white",
"position": "right",
"items": [
{
"id": "view_user_info",
"label": "$t:sirsoft-board.admin.posts.detail.user_info",
"iconName": "user"
}
]
},
"actions": [
{
"event": "onItemClick",
"type": "click",
"handler": "switch",
"value": "{{$args[0]}}",
"cases": {
"view_user_info": {
"handler": "navigate",
"params": {
"path": "/admin/users/{{comment?.author?.uuid}}"
}
}
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{comment?.author?.is_guest}}",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "{{comment?.author?.name}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "({{comment?.author?.email}})",
"if": "{{comment?.author?.email}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.posts.guest",
"if": "{{comment?.author.is_guest}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
},
"text": "$t:sirsoft-board.admin.posts.detail.admin_badge",
"if": "{{comment?.author.is_admin}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "· {{comment?.created_at}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"if": "{{comment?.ip_address}}",
"text": "· IP: {{comment?.ip_address}}"
}
]
}
]
}
]
},
{
"id": "comment_right_section",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center flex-shrink-0"
},
"children": [
{
"id": "reply_button",
"type": "basic",
"name": "Button",
"if": "{{post?.data?.board?.use_reply && (comment?.depth ?? 0) < (post?.data?.board?.max_comment_depth ?? 10) && comment?.status !== 'blinded' && comment?.status !== 'deleted' && !comment?.deleted_at && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{post?.data?.abilities?.can_write_comments !== true}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"replyingTo": "{{_local.replyingTo === comment?.id ? null : comment?.id}}",
"editingComment": null,
"replySubmitting": false,
"reply_comment_content": ""
}
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "comment",
"className": "w-3.5 h-3.5"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.reply"
}
]
},
{
"comment": "신고 버튼 — 미신고 상태 (모달 오픈)",
"id": "report_button",
"type": "basic",
"name": "Button",
"if": "{{post?.data?.board?.use_report && comment?.author?.uuid !== current_user?.data?.uuid && comment?.status !== 'blinded' && comment?.status !== 'deleted' && !comment?.deleted_at && !comment?.is_already_reported}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded transition-colors"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"modal_data": {
"id": "{{comment?.id}}",
"type": "comment",
"author": "{{comment?.author?.name}}"
}
}
},
{
"handler": "openModal",
"target": "report_modal"
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "flag",
"className": "w-3.5 h-3.5"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.report"
}
]
},
{
"comment": "신고 버튼 — 이미 신고한 상태 (클릭 불가)",
"type": "basic",
"name": "Button",
"if": "{{post?.data?.board?.use_report && comment?.author?.uuid !== current_user?.data?.uuid && comment?.status !== 'blinded' && comment?.status !== 'deleted' && !comment?.deleted_at && comment?.is_already_reported}}",
"props": {
"disabled": true,
"className": "inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-400 dark:text-gray-500 rounded cursor-not-allowed opacity-60"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "flag",
"className": "w-3.5 h-3.5"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.reported"
}
]
},
{
"id": "comment_action_menu",
"type": "composite",
"name": "ActionMenu",
"props": {
"triggerLabel": "",
"triggerIconName": "ellipsis-vertical",
"position": "right",
"items": "{{comment?.deleted_at ? [{id: 'restore', label: '$t:sirsoft-board.admin.posts.actions.restore', iconName: 'arrows-rotate', variant: 'success', disabled: comment?.abilities?.can_manage !== true}] : [{id: 'edit', label: '$t:sirsoft-board.admin.posts.detail.edit', iconName: 'pencil', disabled: !((comment?.is_author && comment?.abilities?.can_write_comments) || comment?.abilities?.can_manage)}, ...((comment?.status === 'published') ? [{id: 'blind', label: '$t:sirsoft-board.admin.posts.actions.blind', iconName: 'eye-slash', variant: 'warning', disabled: comment?.abilities?.can_manage !== true}] : []), ...((comment?.status === 'blinded') ? [{id: 'restore', label: '$t:sirsoft-board.admin.posts.actions.restore', iconName: 'arrows-rotate', variant: 'success', disabled: comment?.abilities?.can_manage !== true}] : []), {id: 'delete', label: '$t:common.delete', iconName: 'trash', variant: 'danger', disabled: !((comment?.is_author && comment?.abilities?.can_write_comments) || comment?.abilities?.can_manage)}]}}"
},
"actions": [
{
"event": "onItemClick",
"type": "click",
"handler": "switch",
"value": "{{$args[0]}}",
"cases": {
"edit": {
"handler": "setState",
"params": {
"target": "local",
"editingComment": "{{comment?.id}}",
"editingCommentPostId": "{{comment?.post_id}}",
"edit_comment_content": "{{comment?.content}}",
"replyingTo": null,
"editSubmitting": false
}
},
"blind": {
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"blind_modal_data": {
"id": "{{comment?.id}}",
"slug": "{{route.slug}}",
"type": "comment"
}
}
},
{
"handler": "openModal",
"target": "blind_modal"
}
]
},
"restore": {
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"restore_modal_data": {
"id": "{{comment?.id}}",
"slug": "{{route.slug}}",
"type": "comment"
}
}
},
{
"handler": "openModal",
"target": "restore_modal"
}
]
},
"delete": {
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"modal_data": {
"id": "{{comment?.id}}",
"type": "comment"
}
}
},
{
"handler": "openModal",
"target": "delete_modal"
}
]
}
}
}
]
}
]
}
]
},
{
"id": "comment_content_wrapper",
"type": "basic",
"name": "Div",
"if": "{{_local.editingComment !== comment.id}}",
"children": [
{
"id": "deleted_comment_content",
"type": "basic",
"name": "Div",
"if": "{{comment?.deleted_at}}",
"props": {
"className": "mt-1.5 px-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center flex-wrap gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "trash",
"className": "w-3.5 h-3.5 text-red-400 dark:text-red-500"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.deleted_comment_notice"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{comment?.abilities?.can_manage !== true}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"showDeletedCommentId": "{{_local.showDeletedCommentId === comment?.id ? null : comment?.id}}"
}
}
],
"text": "{{_local.showDeletedCommentId === comment?.id ? '$t:sirsoft-board.admin.posts.detail.hide_original_content' : '$t:sirsoft-board.admin.posts.detail.view_original_content'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.showDeletedCommentId === comment?.id}}",
"props": {
"className": "mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-gray-600 dark:text-gray-400 whitespace-pre-line leading-relaxed"
},
"text": "{{comment.content}}"
}
]
},
{
"id": "blinded_comment_content",
"type": "basic",
"name": "Div",
"if": "{{comment?.status === 'blinded' && !comment?.deleted_at}}",
"props": {
"className": "mt-1.5 px-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center flex-wrap gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center flex-wrap gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "eye-slash",
"className": "w-3.5 h-3.5 text-gray-400 dark:text-gray-500"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.blinded_comment_notice"
},
{
"type": "basic",
"name": "Span",
"if": "{{comment?.abilities?.can_manage && (comment?.action_logs ?? []).filter(log => log.action === 'blind').length > 0}}",
"props": {
"className": "text-amber-600 dark:text-amber-400"
},
"text": "($t:sirsoft-board.admin.posts.detail.blind_reason_label {{(comment?.action_logs ?? []).filter(log => log.action === 'blind').slice(-1)[0]?.reason ?? '-'}})"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{comment?.abilities?.can_manage !== true}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"showBlindedCommentId": "{{_local.showBlindedCommentId === comment?.id ? null : comment?.id}}"
}
}
],
"text": "{{_local.showBlindedCommentId === comment?.id ? '$t:sirsoft-board.admin.posts.detail.hide_original_content' : '$t:sirsoft-board.admin.posts.detail.view_original_content'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.showBlindedCommentId === comment?.id}}",
"props": {
"className": "mt-2 p-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm text-gray-600 dark:text-gray-400 whitespace-pre-line leading-relaxed"
},
"text": "{{comment.content}}"
}
]
},
{
"id": "normal_comment_content",
"type": "basic",
"name": "Div",
"if": "{{comment?.status !== 'blinded' && !comment?.deleted_at}}",
"props": {
"className": "mt-1.5 px-4 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line leading-relaxed"
},
"text": "{{comment.content}}"
}
]
},
{
"id": "comment_edit_form",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-3 pl-10"
},
"if": "{{_local.editingComment === comment.id}}",
"children": [
{
"type": "composite",
"name": "Textarea",
"props": {
"value": "{{_local.edit_comment_content ?? comment.content}}",
"rows": 3,
"className": "w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white mb-2"
},
"actions": [
{
"type": "input",
"handler": "setState",
"params": {
"target": "local",
"edit_comment_content": "{{$event.target.value}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-2"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"editingComment": null,
"edit_comment_content": ""
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.cancel"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_local.editSubmitting === true}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"if": "{{_local.editSubmitting !== true}}",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"editSubmitting": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts/{{comment?.post_id}}/comments/{{comment?.id}}",
"params": {
"method": "PUT",
"body": {
"content": "{{_local.edit_comment_content}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "post"
}
},
{
"handler": "setState",
"params": {
"target": "local",
"editingComment": null,
"edit_comment_content": "",
"editSubmitting": false
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.posts.detail.comment_edit_success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"editSubmitting": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
},
"if": "{{_local.editSubmitting}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.edit"
}
]
}
]
}
]
}
]
},
{
"id": "replies_toggle_button",
"type": "basic",
"name": "Div",
"if": "{{comment.depth === 0 && comment.replies_count > 0}}",
"props": {
"className": "mt-2 px-4"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "inline-flex items-center gap-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"collapsedReplies": "{{Object.assign({}, _local.collapsedReplies || {}, {[comment.id]: !(_local.collapsedReplies?.[comment.id] ?? true)})}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{(_local.collapsedReplies?.[comment.id] ?? true) ? 'chevron-down' : 'chevron-up'}}",
"className": "w-3.5 h-3.5"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{(_local.collapsedReplies?.[comment.id] ?? true) ? '$t:sirsoft-board.admin.posts.detail.show_replies|count=' + comment.replies_count : '$t:sirsoft-board.admin.posts.detail.hide_replies'}}"
}
]
}
]
}
]
}
@@ -0,0 +1,505 @@
{
"meta": {
"is_partial": true,
"description": "Admin Board Post Detail - Comments Section (Header Separated)"
},
"id": "comments_section",
"type": "basic",
"name": "Div",
"if": "{{post?.data?.board?.use_comment}}",
"props": {
"className": "mt-6"
},
"children": [
{
"id": "comments_header",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 px-6 pb-3 border-b border-gray-200 dark:border-gray-700 pb-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "comments",
"className": "w-5 h-5 text-green-600 dark:text-green-400"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.posts.detail.comments_title|count={{(post?.data?.comments ?? []).length}}"
}
]
},
{
"id": "comments_card",
"type": "basic",
"name": "Div",
"children": [
{
"id": "comments_list_container",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"if": "{{(post?.data?.comments ?? []).length > 0}}",
"children": [
{
"id": "comment_wrapper",
"type": "basic",
"name": "Div",
"iteration": {
"source": "post?.data?.comments ?? []",
"item_var": "comment",
"index_var": "idx"
},
"if": "{{(comment?.depth ?? 0) === 0 || (_local.collapsedReplies?.[$computed.commentRootMap?.[comment.id]] === false)}}",
"children": [
{
"id": "comment_item",
"type": "basic",
"name": "Div",
"props": {
"className": "border-b border-gray-200 dark:border-gray-700 px-4 py-3 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50 transition-colors",
"style": {
"marginLeft": "{{Math.min(comment.depth ?? 0, 10) * 1}}rem"
}
},
"children": [
{
"partial": "_comment.json"
}
]
},
{
"id": "reply_form_for_comment",
"type": "basic",
"name": "Div",
"if": "{{_local.replyingTo === comment.id && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at}}",
"props": {
"className": "px-4 py-3 border-b border-gray-200 dark:border-gray-700",
"style": {
"marginLeft": "{{Math.min((comment.depth ?? 0) + 1, 10) * 1}}rem"
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 mb-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "turn-up",
"className": "w-3.5 h-3.5 fa-rotate-90 mt-1 text-gray-500 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.posts.detail.replying_to|name={{comment.author.name}}"
}
]
},
{
"type": "composite",
"name": "Textarea",
"props": {
"value": "{{_local.reply_comment_content ?? ''}}",
"placeholder": "$t:sirsoft-board.admin.posts.detail.reply_placeholder",
"rows": 2,
"disabled": "{{post?.data?.abilities?.can_write_comments !== true}}",
"className": "w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white mb-2 disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "input",
"handler": "setState",
"params": {
"target": "local",
"reply_comment_content": "{{$event.target.value}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-2"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"replyingTo": null
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.cancel"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{post?.data?.abilities?.can_write_comments !== true || _local.replySubmitting === true}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"if": "{{_local.replySubmitting !== true}}",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"replySubmitting": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts/{{comment?.post_id}}/comments",
"params": {
"method": "POST",
"body": {
"content": "{{_local.reply_comment_content}}",
"parent_id": "{{comment?.id}}",
"depth": "{{(comment?.depth ?? 0) + 1}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "post"
}
},
{
"handler": "setState",
"params": {
"target": "local",
"replyingTo": null,
"replyingToPostId": null,
"replyingToDepth": null,
"reply_comment_content": "",
"replySubmitting": false
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.posts.detail.reply_submit_success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"replySubmitting": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{!_local.replySubmitting}}",
"props": {
"name": "paper-plane",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{_local.replySubmitting}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_local.replySubmitting ? '$t:sirsoft-board.admin.posts.detail.reply_submitting' : '$t:sirsoft-board.admin.posts.detail.reply'}}"
}
]
}
]
}
]
}
]
}
]
}
]
}
]
},
{
"id": "no_comments_message",
"type": "basic",
"name": "Div",
"props": {
"className": "p-12 text-center"
},
"if": "{{(post?.data?.comments ?? []).length === 0}}",
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "comment",
"className": "w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.posts.detail.no_comments"
}
]
},
{
"id": "comment_input_section",
"type": "basic",
"name": "Div",
"if": "{{post?.data?.board?.use_comment && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at}}",
"props": {
"className": "p-6 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "user-circle",
"className": "w-8 h-8 text-gray-400 dark:text-gray-500 flex-shrink-0 mt-1"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "composite",
"name": "Textarea",
"props": {
"value": "{{_local.new_comment_content ?? ''}}",
"placeholder": "$t:sirsoft-board.admin.posts.detail.comment_placeholder",
"rows": 3,
"disabled": "{{post?.data?.abilities?.can_write_comments !== true}}",
"className": "w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "input",
"handler": "setState",
"params": {
"target": "local",
"new_comment_content": "{{$event.target.value}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end mt-2"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 text-sm bg-gray-800 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{post?.data?.abilities?.can_write_comments !== true || _local.commentSubmitting === true}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"if": "{{_local.commentSubmitting !== true}}",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"commentSubmitting": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts/{{route.id}}/comments",
"params": {
"method": "POST",
"body": {
"content": "{{_local.new_comment_content}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "post"
}
},
{
"handler": "setState",
"params": {
"target": "local",
"new_comment_content": "",
"commentSubmitting": false
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.posts.detail.comment_submit_success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"commentSubmitting": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{!_local.commentSubmitting}}",
"props": {
"name": "paper-plane",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{_local.commentSubmitting}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_local.commentSubmitting ? '$t:sirsoft-board.admin.posts.detail.comment_submitting' : '$t:sirsoft-board.admin.posts.detail.comment_submit'}}"
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,459 @@
{
"meta": {
"is_partial": true,
"description": "Admin Board Reply Card Content - Simplified View Only"
},
"type": "basic",
"name": "Div",
"children": [
{
"id": "reply_header",
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "title_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-row items-start justify-between gap-4 mb-4"
},
"responsive": {
"portable": {
"props": {
"className": "flex flex-col gap-3 mb-4"
}
}
},
"children": [
{
"id": "title_with_badges",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 min-w-0"
},
"children": [
{
"id": "title_with_prefix",
"type": "basic",
"name": "H2",
"props": {
"className": "text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2 flex-wrap"
},
"responsive": {
"portable": {
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 flex-wrap"
}
}
},
"children": [
{
"id": "reply_arrow_icon",
"type": "composite",
"name": "Icon",
"props": {
"name": "arrow-right",
"className": "w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0"
}
},
{
"id": "category_badge_prefix",
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
},
"text": "{{item.category}}",
"if": "{{item.category}}"
},
{
"id": "notice_prefix",
"type": "basic",
"name": "Span",
"props": {
"className": "text-blue-600 dark:text-blue-400"
},
"text": "$t:sirsoft-board.admin.posts.badge.notice",
"if": "{{item.is_notice}}"
},
{
"id": "title_text",
"type": "basic",
"name": "Span",
"text": "{{item.title}}"
},
{
"id": "attachment_icon",
"type": "composite",
"name": "Icon",
"if": "{{item.has_attachment}}",
"props": {
"name": "paperclip",
"className": "w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0"
}
},
{
"id": "new_badge",
"type": "basic",
"name": "Span",
"if": "{{item.is_new}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
},
"text": "$t:sirsoft-board.common.new"
},
{
"id": "secret_icon",
"type": "composite",
"name": "Icon",
"props": {
"name": "lock",
"className": "w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0"
},
"if": "{{item.is_secret}}"
},
{
"id": "status_badge_blinded",
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
},
"text": "$t:sirsoft-board.admin.posts.detail.status_blinded",
"if": "{{item.status === 'blinded'}}"
},
{
"id": "status_badge_deleted",
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-800 text-white dark:bg-gray-600 dark:text-gray-100"
},
"text": "$t:sirsoft-board.admin.posts.detail.status_deleted",
"if": "{{item.deleted_at}}"
}
]
}
]
},
{
"id": "detail_button",
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-gray-800 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-600 transition-colors"
},
"if": "{{item.id != route.id}}",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{item.id}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.view_detail_button"
}
]
}
]
},
{
"id": "reply_meta",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
},
"children": [
{
"id": "author_name_menu",
"type": "composite",
"name": "ActionMenu",
"if": "{{!item.author?.is_guest}}",
"props": {
"triggerLabel": "{{item.author?.name}}",
"triggerIconName": "",
"triggerClassName": "font-medium text-gray-900 dark:text-white",
"position": "right",
"items": [
{
"id": "view_user_info",
"label": "$t:sirsoft-board.admin.posts.detail.user_info",
"iconName": "user"
}
]
},
"actions": [
{
"event": "onItemClick",
"type": "click",
"handler": "switch",
"value": "{{$args[0]}}",
"cases": {
"view_user_info": {
"handler": "navigate",
"params": {
"path": "/admin/users/{{item.author?.uuid}}"
}
}
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{item.author?.is_guest}}",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{item.author?.name}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "({{item.author?.email}})",
"if": "{{item.author?.email}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.posts.guest",
"if": "{{item.author?.is_guest}}"
},
{
"type": "basic",
"name": "Span",
"text": "·"
},
{
"type": "basic",
"name": "Span",
"text": "{{item.created_at}}"
},
{
"id": "comment_count_separator",
"type": "basic",
"name": "Span",
"if": "{{(item.comment_count ?? 0) > 0}}",
"text": "·"
},
{
"id": "comment_count",
"type": "basic",
"name": "Div",
"if": "{{(item.comment_count ?? 0) > 0}}",
"props": {
"className": "flex items-center gap-1"
},
"children": [
{
"type": "composite",
"name": "Icon",
"props": {
"name": "comment",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.comments_title|count={{item.comment_count}}"
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{item.ip_address}}",
"text": "·"
},
{
"type": "basic",
"name": "Span",
"if": "{{item.ip_address}}",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "IP: {{item.ip_address}}"
}
]
}
]
},
{
"id": "attachments_section",
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
},
"if": "{{(item.attachments ?? []).length > 0}}",
"children": [
{
"id": "attachments_toggle",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"showAttachments": "{{!_local.showAttachments}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "composite",
"name": "Icon",
"props": {
"name": "paperclip",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.posts.detail.attachments|count={{(item.attachments ?? []).length}}"
}
]
},
{
"type": "composite",
"name": "Icon",
"props": {
"name": "{{_local.showAttachments ? 'chevron-up' : 'chevron-down'}}",
"className": "w-4 h-4 text-gray-500 dark:text-gray-400"
}
}
]
},
{
"id": "attachments_list",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-3 ml-4 grid grid-cols-1 gap-2"
},
"if": "{{_local.showAttachments}}",
"iteration": {
"source": "item.attachments ?? []",
"item_var": "attachment",
"index_var": "index"
},
"children": [
{
"id": "attachment_card",
"type": "basic",
"name": "A",
"props": {
"href": "{{attachment.download_url}}",
"className": "flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 hover:border-gray-300 dark:border-gray-600 dark:hover:border-gray-500 transition-all cursor-pointer group no-underline"
},
"children": [
{
"type": "composite",
"name": "Icon",
"props": {
"name": "download",
"className": "w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:text-blue-400 dark:group-hover:text-blue-400 transition-colors flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-900 dark:text-white truncate flex-1"
},
"text": "{{attachment.original_filename}} ({{attachment.size_formatted}})"
}
]
}
]
}
]
},
{
"id": "reply_content",
"type": "basic",
"name": "Div",
"props": {
"className": "p-6"
},
"children": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{item?.content}}",
"isHtml": "{{(item?.content_mode ?? 'text') === 'html'}}",
"className": "{{_local.expandedReplies?.[item.id] ? '' : 'line-clamp-3'}} text-gray-900 dark:text-gray-100"
}
},
{
"type": "basic",
"name": "Button",
"if": "{{(item?.content?.length ?? 0) > 150}}",
"props": {
"type": "button",
"className": "mt-2 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"expandedReplies": "{{Object.assign({}, _local.expandedReplies || {}, {[item.id]: !_local.expandedReplies?.[item.id]})}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{_local.expandedReplies?.[item.id] ? '$t:sirsoft-board.admin.posts.form.hide_content' : '$t:sirsoft-board.admin.posts.form.show_full_content'}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{_local.expandedReplies?.[item.id] ? 'chevron-up' : 'chevron-down'}}",
"className": "w-3 h-3"
}
}
]
}
]
}
]
}
@@ -0,0 +1,108 @@
{
"meta": {
"is_partial": true,
"description": "Post Form - Attachments Section (Compact List Style)"
},
"id": "attachments_card",
"type": "basic",
"name": "Div",
"if": "{{form_meta?.data?.board?.use_file_upload}}",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-4"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "paperclip",
"className": "w-5 h-5 text-gray-700 dark:text-gray-300"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.posts.form.attachments"
}
]
},
{
"type": "composite",
"name": "FileUploader",
"props": {
"disabled": "{{_computed.isReadOnly}}",
"collection": "post_attachments",
"maxFiles": "{{form_meta?.data?.board?.max_file_count ?? 5}}",
"maxSize": "{{form_meta?.data?.board?.max_file_size ?? 10}}",
"accept": "{{form_meta?.data?.board?.allowed_extensions ? '.' + form_meta.data.board.allowed_extensions.join(',.') : '.jpg,.jpeg,.png,.gif,.pdf,.zip'}}",
"initialFiles": "{{form_meta?.data?.attachments ?? []}}",
"autoUpload": false,
"uploadTriggerEvent": "upload:board_attachments",
"compact": true,
"confirmBeforeRemove": true,
"confirmRemoveTitle": "$t:sirsoft-board.admin.posts.form.confirm_remove_attachment_title",
"confirmRemoveMessage": "$t:sirsoft-board.admin.posts.form.confirm_remove_attachment_message",
"uploadParams": {
"temp_key": "{{_local.tempKey}}"
},
"apiEndpoints": {
"upload": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments",
"delete": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments/:id",
"reorder": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments/reorder"
}
},
"actions": [
{
"event": "onFilesChange",
"type": "change",
"handler": "setState",
"if": "{{!_local.hasChanges}}",
"params": {
"target": "local",
"hasChanges": true
}
},
{
"event": "onUploadError",
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"uploadError": "{{$args[0]}}",
"isSaving": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{$args[0]}}",
"icon": "triangle-exclamation"
}
}
]
}
]
}
]
}
@@ -0,0 +1,416 @@
{
"meta": {
"is_partial": true,
"description": "Post Form - All Form Fields in One Card"
},
"id": "post_form_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "main_form_card",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 space-y-6"
},
"children": [
{
"id": "author_section",
"type": "basic",
"name": "Div",
"if": "{{route?.id && form_meta?.data?.author}}",
"props": {
"className": "mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 gap-4"
},
"children": [
{
"id": "field_author_name",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.posts.form.author_info"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{form_meta?.data?.author?.email ? form_meta.data.author.name + ' (' + form_meta.data.author.email + ')' : form_meta?.data?.author?.name}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{form_meta?.data?.author?.is_guest}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.posts.guest"
}
]
}
]
},
{
"id": "field_created_at",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.posts.form.created_at"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{form_meta?.data?.created_at}}"
}
]
}
]
}
]
}
]
},
{
"id": "post_options_section",
"type": "basic",
"name": "Div",
"if": "{{form_meta?.data?.board?.user_abilities?.can_manage || form_meta?.data?.board?.secret_mode !== 'disabled'}}",
"props": {
"className": "mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-gray-500 dark:text-gray-400 mb-3"
},
"text": "$t:sirsoft-board.admin.posts.form.post_options"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 gap-4"
},
"children": [
{
"id": "notice_card",
"type": "basic",
"name": "Label",
"if": "{{!_local.form?.parent_id}}",
"props": {
"disabled": "{{_computed.isReadOnly || !form_meta?.data?.board?.user_abilities?.can_manage}}",
"className": "bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 cursor-pointer hover:border-blue-400 dark:hover:border-blue-600 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "is_notice",
"disabled": "{{_computed.isReadOnly || !form_meta?.data?.board?.user_abilities?.can_manage}}",
"className": "mt-0.5 w-4 h-4 text-blue-600 dark:text-blue-400 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "bullhorn",
"className": "w-4 h-4 text-blue-600 dark:text-blue-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.posts.form.is_notice"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.posts.form.notice_description"
}
]
}
]
}
]
},
{
"id": "secret_card",
"type": "basic",
"name": "Label",
"if": "{{form_meta?.data?.board?.secret_mode !== 'disabled'}}",
"props": {
"className": "bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 cursor-pointer hover:border-amber-400 dark:hover:border-amber-600 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "is_secret",
"disabled": "{{_computed.isReadOnly || form_meta?.data?.board?.secret_mode === 'always'}}",
"className": "mt-0.5 w-4 h-4 text-amber-600 bg-gray-100 border-gray-300 rounded focus:ring-amber-500 dark:focus:ring-amber-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "lock",
"className": "w-4 h-4 text-amber-600 dark:text-amber-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.posts.form.is_secret"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.posts.form.secret_description"
}
]
}
]
}
]
}
]
}
]
},
{
"id": "category_section",
"type": "basic",
"name": "Div",
"if": "{{form_meta?.data?.board?.categories && form_meta.data.board.categories.length > 0}}",
"props": {
"className": "pb-6"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
},
"text": "$t:sirsoft-board.admin.posts.form.category"
},
{
"type": "basic",
"name": "Select",
"props": {
"name": "category",
"value": "{{_local.form?.category ?? ''}}",
"disabled": "{{_computed.isReadOnly || !!_local.form?.parent_id}}",
"className": "block w-full sm:w-64 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
},
"children": [
{
"type": "basic",
"name": "Option",
"props": {
"value": ""
},
"text": "$t:sirsoft-board.admin.posts.form.select_category"
},
{
"type": "basic",
"name": "Option",
"iteration": {
"source": "form_meta?.data?.board?.categories ?? []",
"item_var": "category"
},
"props": {
"value": "{{category}}"
},
"text": "{{category}}"
}
]
}
]
},
{
"id": "title_section",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.posts.form.title"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "title",
"value": "{{_local.form?.title ?? ''}}",
"disabled": "{{_computed.isReadOnly}}",
"placeholder": "$t:sirsoft-board.admin.posts.form.title_placeholder",
"className": "block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400"
}
}
]
},
{
"id": "content_section",
"type": "composite",
"name": "HtmlEditor",
"props": {
"content": "{{_local.form?.content ?? ''}}",
"isHtml": "{{(_local.form?.content_mode ?? 'text') === 'html'}}",
"disabled": "{{_computed.isReadOnly}}",
"rows": 15,
"placeholder": "$t:sirsoft-board.admin.posts.form.content_placeholder",
"label": "$t:sirsoft-board.admin.posts.form.content",
"showHtmlModeToggle": true,
"name": "content"
},
"actions": [
{
"event": "onChange",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": {
"content": "{{$args[0].target.value}}"
},
"hasChanges": true
}
},
{
"event": "onIsHtmlChange",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": {
"content_mode": "{{$args[0].target.checked ? 'html' : 'text'}}"
},
"hasChanges": true
}
}
]
}
]
}
]
}
@@ -0,0 +1,171 @@
{
"meta": {
"is_partial": true,
"description": "Post Form - Parent Post Information (답글 작성 시에만 표시)"
},
"id": "parent_post_card",
"type": "basic",
"name": "Div",
"if": "{{(form_data?.data?.parent_id || query?.parent_id) && form_meta?.data?.parent_post?.id}}",
"props": {
"className": "bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-4 mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "reply",
"className": "w-4 h-4 text-blue-600 dark:text-blue-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.posts.form.reply_to"
},
{
"type": "basic",
"name": "Span",
"if": "{{form_meta?.data?.parent_post?.is_notice}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
},
"text": "$t:sirsoft-board.admin.posts.notice"
},
{
"type": "basic",
"name": "Span",
"if": "{{form_meta?.data?.parent_post?.is_secret}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300"
},
"text": "$t:sirsoft-board.admin.posts.secret"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-2"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white"
},
"text": "{{form_meta?.data?.parent_post?.title}}"
},
{
"type": "basic",
"name": "A",
"props": {
"href": "/admin/board/{{route.slug}}/{{form_meta?.data?.parent_post?.id}}",
"title": "$t:sirsoft-board.admin.posts.form.view_original",
"className": "text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3.5 h-3.5"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{form_meta?.data?.parent_post?.user?.name ?? '$t:sirsoft-board.admin.posts.unknown_author'}}"
},
{
"type": "basic",
"name": "Span",
"text": "·"
},
{
"type": "basic",
"name": "Span",
"text": "{{form_meta?.data?.parent_post?.created_at}}"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{form_meta?.data?.parent_post?.content}}",
"props": {
"className": "bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded p-3"
},
"children": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{form_meta?.data?.parent_post?.content}}",
"isHtml": "{{(form_meta?.data?.parent_post?.content_mode ?? 'text') === 'html'}}",
"className": "{{_local.parentPostExpanded ? '' : 'line-clamp-3'}} text-sm text-gray-600 dark:text-gray-300 mb-2"
}
},
{
"type": "basic",
"name": "Button",
"if": "{{(form_meta?.data?.parent_post?.content?.length ?? 0) > 150}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"parentPostExpanded": "{{!_local.parentPostExpanded}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{_local.parentPostExpanded ? '$t:sirsoft-board.admin.posts.form.hide_content' : '$t:sirsoft-board.admin.posts.form.show_full_content'}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{_local.parentPostExpanded ? 'chevron-up' : 'chevron-down'}}",
"className": "w-3 h-3"
}
}
]
}
]
}
]
}
@@ -0,0 +1,416 @@
{
"meta": {
"is_partial": true,
"description": "Status Stats Grid and Alert Box with Dynamic Actions for Admin Report Detail View"
},
"id": "status_wrapper",
"type": "basic",
"name": "Div",
"if": "{{report_detail?.data}}",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "status_alert",
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border rounded-lg {{report_detail?.data?.status === 'pending' || report_detail?.data?.status === 'review' || report_detail?.data?.status === 'rejected' || report_detail?.data?.status === 'suspended' || report_detail?.data?.status === 'deleted' ? 'flex flex-row items-end justify-between gap-4' : ''}} {{report_detail?.data?.status === 'pending' ? 'bg-yellow-50 dark:bg-yellow-900 border-yellow-300 dark:border-yellow-800' : report_detail?.data?.status === 'review' ? 'bg-blue-50 dark:bg-blue-900 border-blue-300 dark:border-blue-800' : report_detail?.data?.status === 'rejected' ? 'bg-purple-50 dark:bg-purple-900 border-purple-200 dark:border-purple-700' : report_detail?.data?.status === 'deleted' ? 'bg-gray-200 dark:bg-gray-700 border-gray-400 dark:border-gray-600' : 'bg-red-50 dark:bg-red-900 border-red-300 dark:border-red-800'}}"
},
"responsive": {
"portable": {
"props": {
"className": "px-6 py-4 border rounded-lg {{report_detail?.data?.status === 'pending' || report_detail?.data?.status === 'review' || report_detail?.data?.status === 'rejected' || report_detail?.data?.status === 'suspended' || report_detail?.data?.status === 'deleted' ? 'flex flex-col gap-3' : ''}} {{report_detail?.data?.status === 'pending' ? 'bg-yellow-50 dark:bg-yellow-900 border-yellow-300 dark:border-yellow-800' : report_detail?.data?.status === 'review' ? 'bg-blue-50 dark:bg-blue-900 border-blue-300 dark:border-blue-800' : report_detail?.data?.status === 'rejected' ? 'bg-purple-50 dark:bg-purple-900 border-purple-200 dark:border-purple-700' : report_detail?.data?.status === 'deleted' ? 'bg-gray-200 dark:bg-gray-700 border-gray-400 dark:border-gray-600' : 'bg-red-50 dark:bg-red-900 border-red-300 dark:border-red-800'}}"
}
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "{{report_detail?.data?.status === 'pending' || report_detail?.data?.status === 'review' || report_detail?.data?.status === 'rejected' || report_detail?.data?.status === 'suspended' ? 'flex items-start gap-3 flex-1' : 'flex items-start gap-3'}}"
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{report_detail?.data?.status === 'pending'}}",
"props": {
"name": "exclamation-circle",
"className": "w-5 h-5 text-yellow-600 dark:text-yellow-400"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{report_detail?.data?.status === 'review'}}",
"props": {
"name": "clock",
"className": "w-5 h-5 {{report_detail?.data?.status === 'review' ? 'text-blue-600 dark:text-blue-400' : ''}}"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{report_detail?.data?.status === 'rejected'}}",
"props": {
"name": "times-circle",
"className": "w-5 h-5 text-purple-600 dark:text-purple-400"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{report_detail?.data?.status === 'suspended'}}",
"props": {
"name": "exclamation-triangle",
"className": "w-5 h-5 {{report_detail?.data?.status === 'suspended' ? 'text-red-600 dark:text-red-400' : ''}}"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{report_detail?.data?.status === 'deleted'}}",
"props": {
"name": "ban",
"className": "w-5 h-5 text-gray-700 dark:text-gray-300"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "font-semibold text-base {{report_detail?.data?.status === 'pending' ? 'text-yellow-700 dark:text-yellow-300' : report_detail?.data?.status === 'review' ? 'text-blue-700 dark:text-blue-300' : report_detail?.data?.status === 'rejected' ? 'text-purple-700 dark:text-purple-200' : report_detail?.data?.status === 'deleted' ? 'text-gray-900 dark:text-gray-300' : 'text-red-700 dark:text-red-300'}}"
},
"responsive": {
"portable": {
"props": {
"className": "font-semibold text-sm {{report_detail?.data?.status === 'pending' ? 'text-yellow-700 dark:text-yellow-300' : report_detail?.data?.status === 'review' ? 'text-blue-700 dark:text-blue-300' : report_detail?.data?.status === 'rejected' ? 'text-purple-700 dark:text-purple-200' : report_detail?.data?.status === 'deleted' ? 'text-gray-900 dark:text-gray-300' : 'text-red-700 dark:text-red-300'}}"
}
}
},
"text": "$t:sirsoft-board.admin.reports.detail.alert_title_{{report_detail?.data?.status}}"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm {{report_detail?.data?.status === 'pending' ? 'text-yellow-600 dark:text-yellow-400' : report_detail?.data?.status === 'review' ? 'text-blue-600 dark:text-blue-400' : report_detail?.data?.status === 'rejected' ? 'text-purple-600 dark:text-purple-400' : report_detail?.data?.status === 'deleted' ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}}"
},
"responsive": {
"portable": {
"props": {
"className": "text-xs {{report_detail?.data?.status === 'pending' ? 'text-yellow-600 dark:text-yellow-400' : report_detail?.data?.status === 'review' ? 'text-blue-600 dark:text-blue-400' : report_detail?.data?.status === 'rejected' ? 'text-purple-600 dark:text-purple-400' : report_detail?.data?.status === 'deleted' ? 'text-gray-600 dark:text-gray-400' : 'text-red-600 dark:text-red-400'}}"
}
}
},
"text": "$t:sirsoft-board.admin.reports.detail.alert_message_{{report_detail?.data?.status}}"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{report_detail?.data?.available_actions?.length > 0}}",
"props": {
"className": "flex flex-wrap gap-2 items-end"
},
"children": [
{
"type": "basic",
"name": "Button",
"if": "{{report_detail?.data?.available_actions?.includes('pending') && report_detail?.data?.status !== 'rejected' && report_detail?.data?.status !== 'suspended'}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 text-xs font-medium transition-colors"
}
}
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"actionType": "pending",
"actionReason": ""
}
},
{
"type": "click",
"handler": "openModal",
"target": "status_action_modal"
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chevron-left",
"className": "w-4 h-4"
},
"responsive": {
"portable": {
"props": {
"name": "chevron-left",
"className": "w-3.5 h-3.5"
}
}
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.reports.detail.actions.back_to_pending"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{report_detail?.data?.available_actions?.includes('review')}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-xs font-medium transition-colors"
}
}
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"actionType": "review",
"actionReason": ""
}
},
{
"type": "click",
"handler": "openModal",
"target": "status_action_modal"
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{report_detail?.data?.status === 'pending' ? 'check-circle' : 'clock'}}",
"className": "w-4 h-4"
},
"responsive": {
"portable": {
"props": {
"name": "{{report_detail?.data?.status === 'pending' ? 'check-circle' : 'clock'}}",
"className": "w-3.5 h-3.5"
}
}
}
},
{
"type": "basic",
"name": "Span",
"text": "{{report_detail?.data?.status === 'pending' ? '$t:sirsoft-board.admin.reports.detail.actions.start_review' : '$t:sirsoft-board.admin.reports.detail.actions.re_review'}}"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{report_detail?.data?.available_actions?.includes('rejected')}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-xs font-medium transition-colors"
}
}
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"actionType": "rejected",
"actionReason": ""
}
},
{
"type": "click",
"handler": "openModal",
"target": "status_action_modal"
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "times-circle",
"className": "w-4 h-4"
},
"responsive": {
"portable": {
"props": {
"name": "times-circle",
"className": "w-3.5 h-3.5"
}
}
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.reports.detail.actions.reject"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{report_detail?.data?.available_actions?.includes('suspended')}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-xs font-medium transition-colors"
}
}
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"actionType": "suspended",
"actionReason": ""
}
},
{
"type": "click",
"handler": "openModal",
"target": "status_action_modal"
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "exclamation-triangle",
"className": "w-4 h-4"
},
"responsive": {
"portable": {
"props": {
"name": "exclamation-triangle",
"className": "w-3.5 h-3.5"
}
}
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.reports.detail.actions.suspend"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{report_detail?.data?.available_actions?.includes('deleted')}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium transition-colors"
}
}
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "global",
"actionType": "deleted",
"actionReason": ""
}
},
{
"type": "click",
"handler": "openModal",
"target": "status_action_modal"
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "trash",
"className": "w-4 h-4"
},
"responsive": {
"portable": {
"props": {
"name": "trash",
"className": "w-3.5 h-3.5"
}
}
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.reports.detail.actions.delete"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,124 @@
{
"meta": {
"is_partial": true,
"description": "Histories Card for Admin Report Detail View"
},
"id": "card_history",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
},
"children": [
{
"id": "history_title",
"type": "basic",
"name": "H2",
"props": {
"className": "text-lg font-bold text-gray-900 dark:text-white mb-4"
},
"text": "$t:sirsoft-board.admin.reports.detail.histories_title"
},
{
"id": "history_data_grid",
"type": "composite",
"name": "DataGrid",
"if": "{{report_detail?.data?.histories && report_detail?.data?.histories.length > 0}}",
"props": {
"className": "histories-compact-grid",
"columns": [
{
"field": "created_at",
"header": "$t:sirsoft-board.admin.reports.detail.table.date",
"sortable": false,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "{{row.created_at}}"
}
]
},
{
"field": "processor_name",
"header": "$t:sirsoft-board.admin.reports.detail.table.processor",
"sortable": false,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-900 dark:text-white"
},
"text": "{{row.processor_name}}{{row.processor_email ? ' (' + row.processor_email + ')' : ''}}"
}
]
},
{
"field": "type",
"header": "$t:sirsoft-board.admin.reports.detail.table.action",
"sortable": false,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{row.type === 'reported' ? 'bg-blue-100 text-blue-800 dark:bg-blue-600 dark:text-white' : row.type === 're_reported' ? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-600 dark:text-white' : row.type === 'rejected' ? 'bg-purple-100 text-purple-800 dark:bg-purple-600 dark:text-white' : row.type === 'suspended' ? 'bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-white' : row.type === 'deleted' ? 'bg-gray-600 text-white dark:bg-gray-500 dark:text-white' : row.type === 'restored' ? 'bg-green-100 text-green-800 dark:bg-green-600 dark:text-white' : row.type === 'review' ? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-600 dark:text-white' : 'bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-100'}}"
},
"text": "{{row.type === 'reported' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.reported' : row.type === 're_reported' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.re_reported' : row.type === 'rejected' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.rejected' : row.type === 'suspended' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.suspended' : row.type === 'deleted' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.deleted' : row.type === 'restored' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.restored' : row.type === 'review' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.review' : row.action_label ?? row.type}}"
}
]
},
{
"field": "reason",
"header": "$t:sirsoft-board.admin.reports.detail.table.reason",
"sortable": false,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line break-words max-w-md"
},
"text": "{{row.reason}}"
}
]
}
],
"data": "{{report_detail?.data?.histories ?? []}}",
"sortable": false,
"pagination": false,
"selectable": false,
"showColumnSelector": false,
"responsiveBreakpoint": 768,
"emptyMessage": "$t:sirsoft-board.admin.reports.detail.no_histories"
}
},
{
"id": "history_empty",
"type": "basic",
"name": "Div",
"if": "{{!report_detail?.data?.histories || report_detail?.data?.histories.length === 0}}",
"props": {
"className": "text-center py-8"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.reports.detail.no_histories"
}
]
}
]
}
@@ -0,0 +1,811 @@
{
"meta": {
"is_partial": true,
"description": "Report Info Card for Admin Report Detail View"
},
"id": "card_report_info",
"type": "basic",
"name": "Div",
"children": [
{
"id": "reported_content_card",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-4"
},
"children": [
{
"id": "reported_content_header",
"type": "basic",
"name": "Div",
"props": {
"className": "mb-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "H2",
"props": {
"className": "text-lg font-bold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.reports.detail.report_info_title"
},
{
"type": "basic",
"name": "Span",
"if": "{{report_detail?.data}}",
"props": {
"className": "{{report_detail?.data?.target_type === 'comment' ? 'inline-flex items-center px-3 py-1.5 rounded text-xs font-bold bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' : 'inline-flex items-center px-3 py-1.5 rounded text-xs font-bold bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'}}"
},
"text": "$t:sirsoft-board.enums.report_type.{{report_detail?.data?.target_type}}"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 rounded-lg px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-1.5 text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chart-bar",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-bold"
},
"text": "$t:sirsoft-board.admin.reports.detail.report_summary_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs"
},
"text": "{{report_detail?.data?.reason_summary ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "w-px h-4 bg-gray-300 dark:bg-gray-600"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-1.5 text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "calendar",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-bold"
},
"text": "$t:sirsoft-board.admin.reports.detail.first_reported_at_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs"
},
"text": "{{report_detail?.data?.first_reported_at ?? '-'}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "w-px h-4 bg-gray-300 dark:bg-gray-600"
}
},
{
"id": "target_status_info",
"type": "basic",
"name": "Div",
"if": "{{report_detail?.data?.target_status}}",
"props": {
"className": "flex items-center gap-1.5 text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-bold"
},
"text": "$t:sirsoft-board.admin.reports.detail.target_status_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs {{report_detail?.data?.target_status === 'published' ? 'text-green-600 dark:text-green-400' : report_detail?.data?.target_status === 'blinded' ? 'text-orange-600 dark:text-orange-400' : 'text-gray-500 dark:text-gray-400'}}"
},
"text": "{{report_detail?.data?.target_status_label ?? report_detail?.data?.target_status ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{report_detail?.data?.target_status === 'blinded' && report_detail?.data?.blind_trigger_type_label}}",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "({{report_detail?.data?.blind_trigger_type_label}})"
}
]
}
]
}
]
},
{
"id": "reported_content_grid",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-2 gap-4"
},
"children": [
{
"id": "field_author",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "{{report_detail?.data?.target_type === 'post' ? '$t:sirsoft-board.admin.reports.detail.content_author_label' : '$t:sirsoft-board.admin.reports.detail.comment_author_label'}}"
},
{
"comment": "회원 작성자: ActionMenu로 사용자 정보 드롭다운",
"type": "composite",
"name": "ActionMenu",
"if": "{{!(report_detail?.data?.target_type === 'post' ? report_detail?.data?.post?.author?.is_guest : report_detail?.data?.comment?.author?.is_guest)}}",
"props": {
"triggerLabel": "{{report_detail?.data?.target_type === 'post' ? report_detail?.data?.post?.author?.name : report_detail?.data?.comment?.author?.name}}",
"triggerIconName": "",
"position": "right",
"items": [
{
"id": "view_author",
"label": "$t:sirsoft-board.admin.reports.detail.user_info",
"iconName": "user"
}
]
},
"actions": [
{
"event": "onItemClick",
"type": "click",
"handler": "switch",
"value": "{{$args[0]}}",
"cases": {
"view_author": {
"handler": "navigate",
"params": {
"path": "/admin/users/{{report_detail?.data?.target_type === 'post' ? report_detail?.data?.post?.author?.uuid : report_detail?.data?.comment?.author?.uuid}}"
}
}
}
}
]
},
{
"comment": "비회원 작성자: 이름 + 배지 (드롭다운 없음)",
"type": "basic",
"name": "Div",
"if": "{{report_detail?.data?.target_type === 'post' ? report_detail?.data?.post?.author?.is_guest : report_detail?.data?.comment?.author?.is_guest}}",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{report_detail?.data?.target_type === 'post' ? report_detail?.data?.post?.author?.name : report_detail?.data?.comment?.author?.name}}"
},
{
"type": "composite",
"name": "Badge",
"props": {
"color": "yellow",
"size": "sm"
},
"text": "$t:sirsoft-board.admin.reports.guest_badge"
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{(report_detail?.data?.target_type === 'post' && report_detail?.data?.post?.author?.email) || (report_detail?.data?.target_type === 'comment' && report_detail?.data?.comment?.author?.email)}}",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "{{report_detail?.data?.target_type === 'post' ? report_detail?.data?.post?.author?.email : report_detail?.data?.comment?.author?.email}}"
}
]
},
{
"id": "field_board",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.reports.detail.board_label"
},
{
"type": "basic",
"name": "Span",
"if": "{{report_detail?.data?.board?.slug}}",
"props": {
"className": "inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer transition-colors group"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{report_detail?.data?.board?.slug}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "border-b border-transparent group-hover:border-blue-600 dark:group-hover:border-blue-400 transition-all"
},
"text": "{{report_detail?.data?.board?.name}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3"
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{!report_detail?.data?.board?.slug}}",
"props": {
"className": "inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{report_detail?.data?.board?.name}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-400 dark:text-gray-500/80"
},
"text": "($t:sirsoft-board.admin.reports.detail.board_deleted)"
}
]
}
]
},
{
"id": "field_post_title",
"type": "basic",
"name": "Div",
"if": "{{report_detail?.data?.target_type === 'comment'}}",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.reports.detail.post_title_label"
},
{
"type": "basic",
"name": "Span",
"if": "{{report_detail?.data?.board?.slug && report_detail?.data?.post?.id}}",
"props": {
"className": "inline-flex items-center gap-1.5 text-sm font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer transition-colors group"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{report_detail?.data?.board?.slug}}/post/{{report_detail?.data?.post?.id}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "border-b border-transparent group-hover:border-blue-600 dark:group-hover:border-blue-400 transition-all"
},
"text": "{{report_detail?.data?.post?.title}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3 transform translate-x-0 group-hover:translate-x-0.5 transition-all"
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{!report_detail?.data?.board?.slug || !report_detail?.data?.post?.id}}",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
},
"text": "{{report_detail?.data?.post?.title}}"
}
]
},
{
"id": "field_title",
"type": "basic",
"name": "Div",
"if": "{{report_detail?.data?.target_type === 'post'}}",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.reports.detail.title_label"
},
{
"type": "basic",
"name": "Span",
"if": "{{report_detail?.data?.board?.slug && report_detail?.data?.post?.id}}",
"props": {
"className": "inline-flex items-center gap-1.5 text-sm font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer transition-colors group"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{report_detail?.data?.board?.slug}}/post/{{report_detail?.data?.post?.id}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "border-b border-transparent group-hover:border-blue-600 dark:group-hover:border-blue-400 transition-all"
},
"text": "{{report_detail?.data?.post?.title}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "arrow-up-right-from-square",
"className": "w-3 h-3 transform translate-x-0 group-hover:translate-x-0.5 transition-all"
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{!report_detail?.data?.board?.slug || !report_detail?.data?.post?.id}}",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
},
"text": "{{report_detail?.data?.post?.title}}"
}
]
},
{
"id": "field_created_at",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "{{report_detail?.data?.target_type === 'post' ? '$t:sirsoft-board.admin.reports.detail.post_created_at_label' : '$t:sirsoft-board.admin.reports.detail.comment_created_at_label'}}"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "clock",
"className": "w-4 h-4 text-gray-500 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{report_detail?.data?.target_type === 'post' ? report_detail?.data?.post?.created_at : report_detail?.data?.comment?.created_at}}"
}
]
}
]
}
]
},
{
"id": "reported_content_section",
"type": "basic",
"name": "Div",
"props": {
"className": "pt-4 mt-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2"
},
"text": "$t:sirsoft-board.admin.reports.detail.reported_content_label"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "bg-gray-50 dark:bg-gray-900 rounded-lg p-4"
},
"children": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{report_detail?.data?.target_type === 'post' ? report_detail?.data?.post?.content : report_detail?.data?.comment?.content}}",
"isHtml": "{{report_detail?.data?.target_type === 'post' && (report_detail?.data?.post?.content_mode ?? 'text') === 'html'}}",
"className": "text-sm text-gray-900 dark:text-gray-100"
}
}
]
}
]
}
]
},
{
"id": "reporters_list_card",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-4"
},
"children": [
{
"id": "reporters_list_header",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-4"
},
"children": [
{
"type": "basic",
"name": "H2",
"props": {
"className": "text-lg font-bold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.reports.detail.reports_title"
},
{
"type": "basic",
"name": "Span",
"if": "{{report_detail?.data}}",
"props": {
"className": "inline-flex items-center px-3 py-1.5 rounded text-xs font-bold bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
},
"text": "$t:sirsoft-board.admin.reports.detail.report_count_with_unit|count={{reporters_list?.data?.pagination?.total ?? report_detail?.data?.report_count ?? 0}}"
}
]
},
{
"id": "reporters_list_container",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_local.reporters_loading}}",
"props": {
"className": "flex flex-col gap-3"
},
"iteration": {
"source": "reporters_list?.data?.data ?? report_detail?.data?.reporters ?? []",
"item_var": "reporter"
},
"children": [
{
"id": "reporter_item_card",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow"
},
"children": [
{
"id": "card_inner_grid",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-2 lg:grid-cols-5 gap-4"
},
"children": [
{
"id": "reporter_field",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.reports.detail.reporter_label"
},
{
"type": "composite",
"name": "ActionMenu",
"if": "{{reporter.reporter}}",
"props": {
"triggerLabel": "{{reporter.reporter?.name ?? '-'}}",
"triggerIconName": "",
"position": "right",
"items": [
{
"id": "view_reporter",
"label": "$t:sirsoft-board.admin.reports.detail.user_info",
"iconName": "user"
}
]
},
"actions": [
{
"event": "onItemClick",
"type": "click",
"handler": "switch",
"value": "{{$args[0]}}",
"cases": {
"view_reporter": {
"handler": "navigate",
"params": {
"path": "/admin/users/{{reporter.reporter?.uuid}}"
}
}
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{!reporter.reporter}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.reports.detail.deleted_user"
},
{
"type": "basic",
"name": "Span",
"if": "{{reporter.reporter?.email}}",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "{{reporter.reporter?.email}}"
}
]
},
{
"id": "reported_at_field",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.reports.detail.reported_at_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
},
"text": "{{reporter.reported_at}}"
}
]
},
{
"id": "reason_type_field",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.reports.detail.reason_type_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
},
"text": "$t:sirsoft-board.enums.report_reason_type.{{reporter.reason_type}}"
}
]
},
{
"id": "reporter_item_reason",
"type": "basic",
"name": "Div",
"props": {
"className": "md:col-span-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.reports.detail.reason_detail_label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300 line-clamp-3"
},
"text": "{{reporter.reason_detail}}"
}
]
}
]
}
]
}
]
},
{
"id": "reporters_pagination",
"type": "composite",
"name": "Pagination",
"if": "{{(reporters_list?.data?.pagination?.last_page ?? 1) > 1}}",
"props": {
"currentPage": "{{reporters_list?.data?.pagination?.current_page ?? 1}}",
"totalPages": "{{reporters_list?.data?.pagination?.last_page ?? 1}}",
"className": "mt-4 justify-end"
},
"actions": [
{
"event": "onPageChange",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"reporters_page": "{{$args[0]}}",
"reporters_loading": true
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "reporters_list"
}
},
{
"handler": "setState",
"params": {
"target": "local",
"reporters_loading": false
}
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,485 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 일괄 적용 확인 모달"
},
"id": "bulk_apply_confirm_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.settings.bulk_apply_modal.title",
"size": "lg"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"comment": "저장/되돌릴수없음 경고 배너",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-center gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-amber-800 dark:text-amber-200" },
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.message"
}
]
},
{
"comment": "권한 항목 선택 시 안내 배너",
"type": "basic",
"name": "Div",
"if": "{{(function() { const fields = _global.bulkApplySnapshot?.fields ?? []; return fields.some(f => f.includes('.') || f === 'manager'); })()}}",
"props": {
"className": "bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 flex items-center gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "text-blue-600 dark:text-blue-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-blue-800 dark:text-blue-200" },
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.permission_notice"
}
]
},
{
"comment": "선택된 항목 목록 (섹션별 그루핑)",
"type": "basic",
"name": "Div",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-4 py-2.5 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "list-check",
"size": "sm",
"className": "text-gray-500 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.selected_fields"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center justify-center min-w-5 h-5 px-1.5 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-xs font-bold rounded-full"
},
"text": "{{(_global.bulkApplySnapshot?.fields ?? []).length}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "max-h-48 overflow-y-auto"
},
"children": [
{
"type": "basic",
"name": "Span",
"if": "{{!(_global.bulkApplySnapshot?.fields ?? []).length}}",
"props": {
"className": "block p-3 text-sm text-gray-400 dark:text-gray-500 italic"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.no_fields"
},
{
"comment": "섹션별 그루핑 iteration",
"type": "basic",
"name": "Div",
"iteration": {
"source": "(function() { const fields = _global.bulkApplySnapshot?.fields ?? []; const sectionMap = { type:'basic', show_view_count:'basic', secret_mode:'basic', use_comment:'basic', use_reply:'basic', use_report:'basic', per_page:'list', per_page_mobile:'list', order_by:'list', order_direction:'list', min_title_length:'post', max_title_length:'post', min_content_length:'post', max_content_length:'post', max_reply_depth:'post', blocked_keywords:'post', new_display_hours:'post', comment_order:'comment', min_comment_length:'comment', max_comment_length:'comment', max_comment_depth:'comment', use_file_upload:'attachment', max_file_size:'attachment', max_file_count:'attachment', allowed_extensions:'attachment', notify_admin_on_post:'notification', notify_author:'notification', manager:'permissions' }; const sectionOrder = ['basic','list','post','comment','attachment','notification','permissions']; const grouped = {}; fields.forEach(k => { const sk = k.includes('.') ? 'permissions' : (sectionMap[k] ?? 'basic'); if (!grouped[sk]) grouped[sk] = []; grouped[sk].push(k); }); return sectionOrder.filter(sk => grouped[sk]).map(sk => ({ sectionKey: sk, items: grouped[sk] })); })()",
"item_var": "sectionGroup"
},
"props": {
"className": "border-b border-gray-100 dark:border-gray-700/50 last:border-b-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-3 py-1.5 bg-gray-100 dark:bg-gray-700"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wide" },
"text": "$t:sirsoft-board.admin.settings.sections.{{sectionGroup.sectionKey}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "px-3 py-2 flex flex-wrap gap-1.5" },
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "sectionGroup.items",
"item_var": "fieldKey"
},
"props": {
"className": "inline-flex items-center px-2 py-0.5 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-xs rounded"
},
"children": [
{
"type": "basic",
"name": "Span",
"if": "{{!fieldKey.includes('.') && fieldKey !== 'manager'}}",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.field_labels.{{fieldKey}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{fieldKey.includes('.') || fieldKey === 'manager'}}",
"text": "$t:sirsoft-board.board.permissions.{{fieldKey}}"
}
]
}
]
}
]
}
]
}
]
},
{
"comment": "선택된 게시판 목록 (전체/선택 무관 항상 표시)",
"type": "basic",
"name": "Div",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-4 py-2.5 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "table-columns",
"size": "sm",
"className": "text-gray-500 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.selected_boards"
},
{
"comment": "선택 게시판 개수 배지",
"type": "basic",
"name": "Span",
"if": "{{_global.bulkApplySnapshot?.applyAll === false}}",
"props": {
"className": "inline-flex items-center justify-center min-w-5 h-5 px-1.5 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 text-xs font-bold rounded-full"
},
"text": "{{(_global.bulkApplySnapshot?.boardIds ?? []).length}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-3 flex flex-wrap gap-1.5 min-h-12"
},
"children": [
{
"type": "basic",
"name": "Span",
"if": "{{_global.bulkApplySnapshot?.applyAll !== false}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 italic"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.all_boards"
},
{
"type": "basic",
"name": "Span",
"if": "{{_global.bulkApplySnapshot?.applyAll === false && !(_global.bulkApplySnapshot?.boardIds ?? []).length}}",
"props": {
"className": "text-sm text-gray-400 dark:text-gray-500 italic"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.no_boards"
},
{
"type": "basic",
"name": "Div",
"if": "{{_global.bulkApplySnapshot?.applyAll === false}}",
"iteration": {
"source": "(_global.bulkApplySnapshot?.boardIds ?? []).map(id => { const opt = ($parent._computed.boardOptions ?? []).find(o => o.value == id); return opt ? opt.label : id; })",
"item_var": "boardName"
},
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 text-xs font-medium rounded-full"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{boardName}}"
}
]
}
]
}
]
},
{
"comment": "저장 실패 에러 표시",
"type": "basic",
"name": "Div",
"if": "{{$parent._local.bulkApplySaveError}}",
"props": {
"className": "flex items-center gap-2 text-sm text-red-600 dark:text-red-400 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "circle-exclamation", "className": "w-4 h-4 flex-shrink-0" }
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.save_error"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 pt-2"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{$parent._local.isBulkApplying}}",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "closeModal"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"id": "bulk_apply_confirm_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{$parent._local.isBulkApplying || !(_global.bulkApplySnapshot?.fields ?? []).length || (_global.bulkApplySnapshot?.applyAll === false && !(_global.bulkApplySnapshot?.boardIds ?? []).length)}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-amber-600 dark:bg-amber-700 text-white rounded-lg hover:bg-amber-700 dark:hover:bg-amber-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"comment": "스냅샷은 모달 열기 전(탭 버튼 클릭 시) 이미 _global에 저장됨 - 여기서는 진행 상태만 변경",
"handler": "setState",
"params": {
"target": "$parent._local",
"isBulkApplying": true,
"bulkApplySaveError": false
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/settings",
"params": {
"method": "PUT",
"body": "{{(function() { const snap = _global.bulkApplySnapshot || {}; const tab = snap.tab || 'basic_defaults'; const form = snap.form || {}; if (tab === 'report_policy') { return { _tab: 'report_policy', report_policy: form.report_policy || {} }; } if (tab === 'spam_security') { return { _tab: 'spam_security', spam_security: form.spam_security || {} }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notification_channels: form.notification_channels || {} }; })()}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"hasChanges": false
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/settings/bulk-apply",
"params": {
"method": "POST",
"body": "{{(function() { const snap = _global.bulkApplySnapshot || {}; return { fields: snap.fields ?? [], apply_all: snap.applyAll !== false, board_ids: snap.applyAll !== false ? [] : (snap.boardIds ?? []) }; })()}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"isBulkApplying": false,
"bulkApplyFields": [],
"bulkApplyAll": true
}
},
{
"handler": "setState",
"params": {
"target": "global",
"bulkApplySnapshot": null
}
},
{ "handler": "closeModal" },
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.settings.bulk_apply_modal.success|count={{response?.data?.updated_count ?? 0}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"isBulkApplying": false
}
},
{
"handler": "setState",
"params": {
"target": "global",
"bulkApplySnapshot": null
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.bulk_apply_modal.error"
}
}
]
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"isBulkApplying": false,
"bulkApplySaveError": true
}
},
{
"handler": "setState",
"params": {
"target": "global",
"bulkApplySnapshot": null
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{$parent._local.isBulkApplying}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{!$parent._local.isBulkApplying}}",
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.confirm"
},
{
"type": "basic",
"name": "Span",
"if": "{{$parent._local.isBulkApplying}}",
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.processing"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,641 @@
{
"meta": {
"is_partial": true,
"description": "게시판 메일 템플릿 편집 모달"
},
"id": "modal_board_mail_template_edit",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.title",
"size": "xl",
"closeOnOverlayClick": false
},
"children": [
{
"id": "board_edit_form_container",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{$parent._local.isSaving}}",
"props": {
"className": "flex flex-col max-h-[70vh]"
},
"classMap": {
"key": "{{$parent._computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 overflow-y-auto space-y-5 pr-1"
},
"children": [
{
"id": "board_validation_error_banner",
"type": "basic",
"name": "Div",
"if": "{{$parent._local.templateErrors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"size": "sm",
"className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.validation_error"
},
{
"type": "basic",
"name": "Ul",
"if": "{{typeof $parent._local.templateErrors === 'object' && Object.keys($parent._local.templateErrors).length > 0}}",
"props": {
"className": "mt-2 list-disc list-inside space-y-1"
},
"iteration": {
"source": "Object.entries($parent._local.templateErrors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
"item_var": "errMsg"
},
"children": [
{
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
},
"text": "{{errMsg}}"
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.type_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.types.{{$parent._local.editingTemplate?.type ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-mono px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded"
},
"text": "{{$parent._local.editingTemplate?.type ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "{{($parent._local.editLang ?? $locale) === 'ko' ? 'border-b-2 border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none"
},
"text": "한국어",
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "$parent._local",
"editLang": "ko"
}
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "{{($parent._local.editLang ?? $locale) === 'en' ? 'border-b-2 border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none"
},
"text": "English",
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "$parent._local",
"editLang": "en"
}
}
]
}
]
},
{
"id": "board_edit_subject_ko",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editLang ?? $locale) === 'ko'}}",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.subject"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-red-500 dark:text-red-400 ml-1"
},
"text": "*"
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"value": "{{$parent._local.editingTemplate?.subject?.ko ?? ''}}",
"className": "{{$parent._local.templateErrors?.['subject.ko'] ? 'w-full px-3 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500' : 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500'}}",
"disabled": "{{$parent._computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingTemplate.subject.ko": "{{$event.target.value}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{$parent._local.templateErrors?.['subject.ko']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{$parent._local.templateErrors?.['subject.ko']?.[0] ?? ''}}"
}
]
},
{
"id": "board_edit_subject_en",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editLang ?? $locale) === 'en'}}",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.subject"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"value": "{{$parent._local.editingTemplate?.subject?.en ?? ''}}",
"className": "{{$parent._local.templateErrors?.['subject.en'] ? 'w-full px-3 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500' : 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500'}}",
"disabled": "{{$parent._computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingTemplate.subject.en": "{{$event.target.value}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{$parent._local.templateErrors?.['subject.en']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{$parent._local.templateErrors?.['subject.en']?.[0] ?? ''}}"
}
]
},
{
"id": "board_edit_body_ko",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editLang ?? $locale) === 'ko'}}",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.body"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-red-500 dark:text-red-400 ml-1"
},
"text": "*"
}
]
},
{
"type": "composite",
"name": "HtmlEditor",
"props": {
"content": "{{$parent._local.editingTemplate?.body?.ko ?? ''}}",
"isHtml": true,
"showHtmlModeToggle": false,
"className": "{{$parent._local.templateErrors?.['body.ko'] ? 'min-h-[200px] border border-red-500 rounded-md' : 'min-h-[200px]'}}",
"disabled": "{{$parent._computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingTemplate.body.ko": "{{$event.target.value}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{$parent._local.templateErrors?.['body.ko']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{$parent._local.templateErrors?.['body.ko']?.[0] ?? ''}}"
}
]
},
{
"id": "board_edit_body_en",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editLang ?? $locale) === 'en'}}",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.body"
},
{
"type": "composite",
"name": "HtmlEditor",
"props": {
"content": "{{$parent._local.editingTemplate?.body?.en ?? ''}}",
"isHtml": true,
"showHtmlModeToggle": false,
"className": "{{$parent._local.templateErrors?.['body.en'] ? 'min-h-[200px] border border-red-500 rounded-md' : 'min-h-[200px]'}}",
"disabled": "{{$parent._computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingTemplate.body.en": "{{$event.target.value}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"if": "{{$parent._local.templateErrors?.['body.en']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
},
"text": "{{$parent._local.templateErrors?.['body.en']?.[0] ?? ''}}"
}
]
},
{
"id": "board_edit_is_active_toggle",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3"
},
"children": [
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "boardTemplateIsActive",
"checked": "{{$parent._local.editingTemplate?.is_active ?? true}}",
"disabled": "{{$parent._computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingTemplate.is_active": "{{$event.target.checked}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.is_active_label"
}
]
},
{
"id": "board_edit_variables_info",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editingTemplate?.variables ?? []).length > 0}}",
"props": {
"className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.variables_title"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{$parent._local.editingTemplate?.variables ?? []}}",
"item_var": "v",
"index_var": "vi"
},
"props": {
"className": "inline-flex items-center gap-1 text-xs px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{"
},
{
"type": "basic",
"name": "Span",
"text": "{{v.key}}"
},
{
"type": "basic",
"name": "Span",
"text": "} - {{v.description ?? ''}}"
}
]
}
]
}
]
}
]
},
{
"id": "board_edit_modal_footer",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end items-center pt-4 border-t border-gray-200 dark:border-gray-700 mt-4 flex-shrink-0 sticky bottom-0 bg-white dark:bg-gray-800 pb-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-2"
},
"children": [
{
"id": "board_btn_cancel_edit",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{$parent._local.isSaving ?? false}}"
},
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "modal_board_mail_template_edit"
}
]
},
{
"id": "board_btn_save_template",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 text-sm px-4 py-2 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{$parent._computed.isReadOnly || $parent._local.isSaving ?? false}}"
},
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{$parent._local.isSaving}}",
"props": {
"name": "spinner",
"className": "animate-spin",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{$parent._local.isSaving ? '$t:common.saving' : '$t:common.save'}}"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"isSaving": true,
"templateErrors": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "{{'/api/modules/sirsoft-board/admin/mail-templates/' + $parent._local.editingTemplate?.id}}",
"params": {
"method": "PUT",
"body": "{{(function() { const tpl = $parent._local.editingTemplate ?? {}; return { subject: tpl.subject ?? {}, body: tpl.body ?? {}, is_active: tpl.is_active ?? true }; })()}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"isSaving": false,
"templateErrors": null,
"editingTemplate": null
}
},
{
"handler": "closeModal",
"target": "modal_board_mail_template_edit"
},
{
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {}
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-board.admin.settings.mail_templates.save_success",
"type": "success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"isSaving": false,
"templateErrors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-board.admin.settings.mail_templates.save_error",
"type": "error"
}
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,101 @@
{
"meta": {
"is_partial": true,
"description": "게시판 메일 템플릿 미리보기 모달"
},
"id": "modal_board_mail_template_preview",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.settings.mail_templates.preview_modal.title",
"size": "lg"
},
"children": [
{
"id": "board_preview_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.preview_modal.subject_label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-base font-medium text-gray-900 dark:text-white bg-gray-50 dark:bg-gray-900 rounded-md px-3 py-2"
},
"text": "{{_local.previewData?.subject ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.preview_modal.body_label"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden",
"dangerouslySetInnerHTML": "{{_local.previewData?.body ?? ''}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end pt-3 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"id": "board_btn_close_preview",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
},
"text": "$t:common.close",
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "modal_board_mail_template_preview"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,151 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 > 첨부파일 탭"
},
"id": "attachment",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_attachment",
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-2 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.settings.sections.attachment"
},
{
"id": "field_use_file_upload",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('use_file_upload') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_file_upload') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_file_upload') : [...(_local.bulkApplyFields ?? []), 'use_file_upload']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('use_file_upload')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.use_file_upload" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_file_upload" }
]}
]
},
{ "type": "composite", "name": "Toggle", "props": { "name": "basic_defaults.use_file_upload", "className": "flex-shrink-0", "disabled": "{{_computed.isReadOnly}}" } }
]
}
]
},
{
"id": "field_max_file_size",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('max_file_size') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('max_file_size') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'max_file_size') : [...(_local.bulkApplyFields ?? []), 'max_file_size']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('max_file_size')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.max_file_size" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.max_file_size" }
]}
]
},
{ "type": "basic", "name": "Div", "props": { "className": "flex-shrink-0" }, "children": [
{ "type": "basic", "name": "Input", "props": { "type": "number", "name": "basic_defaults.max_file_size", "min": 1, "className": "{{(_local.errors?.['basic_defaults.max_file_size']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}", "disabled": "{{_computed.isReadOnly}}" } },
{ "type": "basic", "name": "Span", "if": "{{_local.errors?.['basic_defaults.max_file_size']}}", "props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" }, "text": "{{(Array.isArray(_local.errors?.['basic_defaults.max_file_size']) ? _local.errors['basic_defaults.max_file_size'][0] : _local.errors?.['basic_defaults.max_file_size']) ?? ''}}" }
]}
]
}
]
},
{
"id": "field_max_file_count",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('max_file_count') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('max_file_count') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'max_file_count') : [...(_local.bulkApplyFields ?? []), 'max_file_count']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('max_file_count')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.max_file_count" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.max_file_count" }
]}
]
},
{ "type": "basic", "name": "Div", "props": { "className": "flex-shrink-0" }, "children": [
{ "type": "basic", "name": "Input", "props": { "type": "number", "name": "basic_defaults.max_file_count", "min": 1, "max": 20, "className": "{{(_local.errors?.['basic_defaults.max_file_count']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}", "disabled": "{{_computed.isReadOnly}}" } },
{ "type": "basic", "name": "Span", "if": "{{_local.errors?.['basic_defaults.max_file_count']}}", "props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" }, "text": "{{(Array.isArray(_local.errors?.['basic_defaults.max_file_count']) ? _local.errors['basic_defaults.max_file_count'][0] : _local.errors?.['basic_defaults.max_file_count']) ?? ''}}" }
]}
]
}
]
},
{
"id": "field_allowed_extensions",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('allowed_extensions') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "px-3 py-2" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer mb-2" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('allowed_extensions') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'allowed_extensions') : [...(_local.bulkApplyFields ?? []), 'allowed_extensions']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('allowed_extensions')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.allowed_extensions" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.allowed_extensions" }
]}
]
},
{
"type": "composite", "name": "TagInput",
"props": {
"name": "basic_defaults.allowed_extensions",
"placeholder": "$t:sirsoft-board.admin.settings.fields.allowed_extensions_placeholder",
"creatable": true,
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,464 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 > 기본 탭 (기본 설정 + 일괄 적용)"
},
"id": "basic",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"comment": "========== 페이지 안내 배너 ==========",
"id": "page_info_banner",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-blue-700 dark:text-blue-300"
},
"text": "$t:sirsoft-board.admin.settings.page_info"
}
]
},
{
"comment": "========== 섹션 1: 기본 설정 ==========",
"id": "section_basic",
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-1 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.settings.sections.basic"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 mb-4"
},
"text": "$t:sirsoft-board.admin.settings.sections.basic_description"
},
{
"id": "field_type",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('type') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('type') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'type') : [...(_local.bulkApplyFields ?? []), 'type']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0",
"checked": "{{(_local.bulkApplyFields ?? []).includes('type')}}"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.type"
}
]
},
{
"id": "settings_btn_manage_board_types",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"variant": "ghost",
"size": "sm",
"className": "inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
"disabled": "{{_computed.isReadOnly}}"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "gear",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.board_types.manage"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"btFormMode": "add",
"btEditingItem": null,
"btFormSlug": "",
"btFormName": {
"ko": "",
"en": ""
}
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board-types",
"params": {
"method": "GET"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"boardTypesList": "{{response.data}}"
}
},
{
"handler": "openModal",
"target": "board_type_manage_modal"
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 pl-6 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.type"
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.type",
"options": "{{board_types?.data?.map(t => ({value: t.slug, label: (t.name[_global.locale ?? 'ko'] ?? t.name.ko ?? t.slug) + ' (' + t.slug + ')'})) ?? []}}",
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_secret_mode",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('secret_mode') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('secret_mode') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'secret_mode') : [...(_local.bulkApplyFields ?? []), 'secret_mode']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('secret_mode')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.secret_mode"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.secret_mode"
}
]
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.secret_mode",
"options": [
{ "value": "disabled", "label": "$t:sirsoft-board.admin.settings.options.secret_mode.disabled" },
{ "value": "enabled", "label": "$t:sirsoft-board.admin.settings.options.secret_mode.enabled" },
{ "value": "always", "label": "$t:sirsoft-board.admin.settings.options.secret_mode.always" }
],
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_show_view_count",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('show_view_count') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('show_view_count') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'show_view_count') : [...(_local.bulkApplyFields ?? []), 'show_view_count']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('show_view_count')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.show_view_count"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.show_view_count"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.show_view_count",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_use_report",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('use_report') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_report') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_report') : [...(_local.bulkApplyFields ?? []), 'use_report']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('use_report')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.use_report"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_report"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.use_report",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,346 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 > 일괄 적용 섹션"
},
"id": "bulk_apply",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"comment": "========== 섹션 9: 기존 게시판에 일괄 적용 ==========",
"id": "section_bulk_apply",
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-4 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.title"
},
{
"type": "basic",
"name": "Div",
"if": "{{(boards_list?.data?.data ?? []).length > 0}}",
"props": {
"className": "space-y-2 mb-6"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg flex items-center gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.checked_fields_notice"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{!(_local.bulkApplyFields ?? []).length}}",
"props": {
"className": "flex items-center gap-2 px-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-exclamation",
"className": "text-amber-600 dark:text-amber-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-amber-700 dark:text-amber-300"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.no_fields_selected"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(_local.bulkApplyFields ?? []).length > 0 && _local.bulkApplyAll === false && !(_local._bulkApplyBoardIds ?? []).length}}",
"props": {
"className": "flex items-center gap-2 px-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-exclamation",
"className": "text-red-500 dark:text-red-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.no_boards_selected"
}
]
}
]
},
{
"id": "no_boards_notice",
"type": "basic",
"name": "Div",
"if": "{{!(boards_list?.data?.data ?? []).length}}",
"props": {
"className": "p-4 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg flex items-center gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "text-gray-500 dark:text-gray-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply.no_boards_notice"
}
]
},
{
"id": "bulk_apply_target",
"type": "basic",
"name": "Div",
"if": "{{(boards_list?.data?.data ?? []).length > 0}}",
"props": {
"className": "space-y-2 mb-6"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.target_boards"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "_bulkApplyAll",
"className": "radio",
"checked": "{{_local.bulkApplyAll !== false}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyAll": true
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.apply_all"
}
]
},
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "_bulkApplyAll",
"className": "radio",
"checked": "{{_local.bulkApplyAll === false}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyAll": false
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.apply_selected"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_local.bulkApplyAll === false}}",
"props": {
"className": "mt-3"
},
"children": [
{
"type": "composite",
"name": "TagInput",
"props": {
"value": "{{_local._bulkApplyBoardIds ?? []}}",
"options": "{{$computed.boardOptions ?? []}}",
"placeholder": "$t:sirsoft-board.admin.settings.bulk_apply.select_boards_placeholder",
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"_bulkApplyBoardIds": "{{$event.target.value}}"
}
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(boards_list?.data?.data ?? []).length > 0}}",
"props": {
"className": "flex justify-end"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || !(_local.bulkApplyFields ?? []).length || (_local.bulkApplyAll === false && !(_local._bulkApplyBoardIds ?? []).length)}}",
"className": "flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"comment": "모달 열기 전 현재 _local 상태를 _global 스냅샷에 저장 + 오류 상태 초기화",
"handler": "setState",
"params": {
"target": "global",
"bulkApplySnapshot": "{{(function() { const fields = _local.bulkApplyFields ?? []; const applyAll = _local.bulkApplyAll !== false; const boardIds = _local._bulkApplyBoardIds ?? []; const tab = _global.activeBoardSettingsTab || 'basic_defaults'; const form = _local.form || {}; return { fields: fields, applyAll: applyAll, boardIds: boardIds, tab: tab, form: form }; })()}}"
}
},
{
"comment": "이전 오류 메시지 초기화",
"handler": "setState",
"params": {
"target": "local",
"bulkApplySaveError": false
}
},
{
"handler": "openModal",
"target": "bulk_apply_confirm_modal"
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply.apply_button"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,172 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 > 댓글 탭"
},
"id": "comment",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_comment",
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-2 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.settings.sections.comment"
},
{
"id": "field_use_comment",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('use_comment') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_comment') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_comment') : [...(_local.bulkApplyFields ?? []), 'use_comment']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('use_comment')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.use_comment" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_comment" }
]}
]
},
{ "type": "composite", "name": "Toggle", "props": { "name": "basic_defaults.use_comment", "className": "flex-shrink-0", "disabled": "{{_computed.isReadOnly}}" } }
]
}
]
},
{
"id": "field_max_comment_depth",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('max_comment_depth') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('max_comment_depth') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'max_comment_depth') : [...(_local.bulkApplyFields ?? []), 'max_comment_depth']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('max_comment_depth')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.max_comment_depth" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.max_comment_depth" }
]}
]
},
{ "type": "basic", "name": "Div", "props": { "className": "flex-shrink-0" }, "children": [
{ "type": "basic", "name": "Input", "props": { "type": "number", "name": "basic_defaults.max_comment_depth", "min": 0, "max": 10, "className": "{{(_local.errors?.['basic_defaults.max_comment_depth']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}", "disabled": "{{_computed.isReadOnly}}" } },
{ "type": "basic", "name": "Span", "if": "{{_local.errors?.['basic_defaults.max_comment_depth']}}", "props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" }, "text": "{{(Array.isArray(_local.errors?.['basic_defaults.max_comment_depth']) ? _local.errors['basic_defaults.max_comment_depth'][0] : _local.errors?.['basic_defaults.max_comment_depth']) ?? ''}}" }
]}
]
}
]
},
{
"id": "field_comment_order",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('comment_order') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('comment_order') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'comment_order') : [...(_local.bulkApplyFields ?? []), 'comment_order']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('comment_order')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.comment_order" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.comment_order" }
]}
]
},
{ "type": "composite", "name": "Select", "props": { "name": "basic_defaults.comment_order", "options": [{ "value": "ASC", "label": "$t:sirsoft-board.admin.settings.options.comment_order.ASC" }, { "value": "DESC", "label": "$t:sirsoft-board.admin.settings.options.comment_order.DESC" }], "className": "w-48 flex-shrink-0 text-sm", "disabled": "{{_computed.isReadOnly}}" } }
]
}
]
},
{
"id": "field_min_comment_length",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('min_comment_length') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('min_comment_length') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'min_comment_length') : [...(_local.bulkApplyFields ?? []), 'min_comment_length']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('min_comment_length')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.min_comment_length" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.min_comment_length" }
]}
]
},
{ "type": "basic", "name": "Div", "props": { "className": "flex-shrink-0" }, "children": [
{ "type": "basic", "name": "Input", "props": { "type": "number", "name": "basic_defaults.min_comment_length", "min": 1, "max": 1000, "className": "{{(_local.errors?.['basic_defaults.min_comment_length']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}", "disabled": "{{_computed.isReadOnly}}" } },
{ "type": "basic", "name": "Span", "if": "{{_local.errors?.['basic_defaults.min_comment_length']}}", "props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" }, "text": "{{(Array.isArray(_local.errors?.['basic_defaults.min_comment_length']) ? _local.errors['basic_defaults.min_comment_length'][0] : _local.errors?.['basic_defaults.min_comment_length']) ?? ''}}" }
]}
]
}
]
},
{
"id": "field_max_comment_length",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('max_comment_length') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer" },
"actions": [{ "type": "click", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('max_comment_length') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'max_comment_length') : [...(_local.bulkApplyFields ?? []), 'max_comment_length']}}" } }],
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('max_comment_length')}}" } },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.max_comment_length" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.max_comment_length" }
]}
]
},
{ "type": "basic", "name": "Div", "props": { "className": "flex-shrink-0" }, "children": [
{ "type": "basic", "name": "Input", "props": { "type": "number", "name": "basic_defaults.max_comment_length", "min": 1, "max": 10000, "className": "{{(_local.errors?.['basic_defaults.max_comment_length']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}", "disabled": "{{_computed.isReadOnly}}" } },
{ "type": "basic", "name": "Span", "if": "{{_local.errors?.['basic_defaults.max_comment_length']}}", "props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" }, "text": "{{(Array.isArray(_local.errors?.['basic_defaults.max_comment_length']) ? _local.errors['basic_defaults.max_comment_length'][0] : _local.errors?.['basic_defaults.max_comment_length']) ?? ''}}" }
]}
]
}
]
}
]
}
]
}
@@ -0,0 +1,384 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 > 목록 탭"
},
"id": "list",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_list",
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-2 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.settings.sections.list"
},
{
"id": "field_order_by",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('order_by') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('order_by') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'order_by') : [...(_local.bulkApplyFields ?? []), 'order_by']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('order_by')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.order_by"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.order_by"
}
]
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.order_by",
"options": [
{ "value": "created_at", "label": "$t:sirsoft-board.admin.settings.options.order_by.created_at" },
{ "value": "view_count", "label": "$t:sirsoft-board.admin.settings.options.order_by.view_count" },
{ "value": "title", "label": "$t:sirsoft-board.admin.settings.options.order_by.title" },
{ "value": "author", "label": "$t:sirsoft-board.admin.settings.options.order_by.author" }
],
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_order_direction",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('order_direction') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('order_direction') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'order_direction') : [...(_local.bulkApplyFields ?? []), 'order_direction']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('order_direction')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.order_direction"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.order_direction"
}
]
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.order_direction",
"options": [
{ "value": "DESC", "label": "$t:sirsoft-board.admin.settings.options.order_direction.DESC" },
{ "value": "ASC", "label": "$t:sirsoft-board.admin.settings.options.order_direction.ASC" }
],
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_per_page",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('per_page') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('per_page') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'per_page') : [...(_local.bulkApplyFields ?? []), 'per_page']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('per_page')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.per_page"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.per_page"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-shrink-0" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "basic_defaults.per_page",
"min": 1,
"max": 100,
"className": "{{(_local.errors?.['basic_defaults.per_page']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}",
"disabled": "{{_computed.isReadOnly}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['basic_defaults.per_page']}}",
"props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" },
"text": "{{(Array.isArray(_local.errors?.['basic_defaults.per_page']) ? _local.errors['basic_defaults.per_page'][0] : _local.errors?.['basic_defaults.per_page']) ?? ''}}"
}
]
}
]
}
]
},
{
"id": "field_per_page_mobile",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('per_page_mobile') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('per_page_mobile') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'per_page_mobile') : [...(_local.bulkApplyFields ?? []), 'per_page_mobile']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('per_page_mobile')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.per_page_mobile"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.per_page_mobile"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-shrink-0" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "basic_defaults.per_page_mobile",
"min": 1,
"max": 100,
"className": "{{(_local.errors?.['basic_defaults.per_page_mobile']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}",
"disabled": "{{_computed.isReadOnly}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['basic_defaults.per_page_mobile']}}",
"props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" },
"text": "{{(Array.isArray(_local.errors?.['basic_defaults.per_page_mobile']) ? _local.errors['basic_defaults.per_page_mobile'][0] : _local.errors?.['basic_defaults.per_page_mobile']) ?? ''}}"
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,190 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 > 알림 탭"
},
"id": "notification",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_notification",
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-2 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.settings.sections.notification"
},
{
"id": "field_notify_author",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('notify_author') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer" },
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('notify_author') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'notify_author' && f !== 'notify_author_channels') : [...(_local.bulkApplyFields ?? []), 'notify_author', 'notify_author_channels']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('notify_author')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.notify_author" },
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" },
"text": "$t:sirsoft-board.admin.settings.fields.notify_author_description"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.notify_author",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
},
{
"id": "field_notify_author_channels",
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-3 px-3 pb-2 ml-6" },
"children": [
{ "type": "basic", "name": "Label", "props": { "className": "text-sm text-gray-600 dark:text-gray-400 flex-shrink-0" }, "text": "$t:sirsoft-board.admin.settings.notification_channels.channels_label" },
{ "type": "composite", "name": "TagInput", "props": { "name": "basic_defaults.notify_author_channels", "options": "{{notification_channels?.data ?? []}}", "className": "w-64 flex-shrink-0" } }
]
}
]
},
{
"id": "field_notify_admin_on_post",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('notify_admin_on_post') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer" },
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('notify_admin_on_post') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'notify_admin_on_post' && f !== 'notify_admin_on_post_channels') : [...(_local.bulkApplyFields ?? []), 'notify_admin_on_post', 'notify_admin_on_post_channels']}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('notify_admin_on_post')}}"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.notify_admin_on_post" },
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" },
"text": "$t:sirsoft-board.admin.settings.fields.notify_admin_on_post_description"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.notify_admin_on_post",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
},
{
"id": "field_notify_admin_channels",
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center gap-3 px-3 pb-2 ml-6" },
"children": [
{ "type": "basic", "name": "Label", "props": { "className": "text-sm text-gray-600 dark:text-gray-400 flex-shrink-0" }, "text": "$t:sirsoft-board.admin.settings.notification_channels.channels_label" },
{ "type": "composite", "name": "TagInput", "props": { "name": "basic_defaults.notify_admin_on_post_channels", "options": "{{notification_channels?.data ?? []}}", "className": "w-64 flex-shrink-0" } }
]
}
]
}
]
}
]
}
@@ -0,0 +1,398 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 > 권한 탭"
},
"id": "permissions",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "section_permissions",
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-2 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.settings.sections.permissions"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
},
"text": "$t:sirsoft-board.admin.settings.permissions_table.description"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 p-3 mb-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-blue-700 dark:text-blue-300" },
"text": "$t:sirsoft-board.admin.settings.permissions_table.bulk_apply_notice"
}
]
},
{
"id": "permissions_admin_section",
"type": "basic",
"name": "Div",
"props": {
"className": "mb-6 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 cursor-pointer select-none"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => k.startsWith('admin.')).every(k => (_local.bulkApplyFields ?? []).includes(k)) ? (_local.bulkApplyFields ?? []).filter(k => !k.startsWith('admin.')) : (_local.bulkApplyFields ?? []).concat(Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => k.startsWith('admin.'))).filter((v, i, a) => a.indexOf(v) === i)}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 pointer-events-none",
"checked": "{{Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => k.startsWith('admin.')).length > 0 && Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => k.startsWith('admin.')).every(k => (_local.bulkApplyFields ?? []).includes(k))}}"
}
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "shield",
"size": "sm",
"className": "text-purple-500 dark:text-purple-400 pointer-events-none"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-base font-semibold text-gray-800 dark:text-gray-100 pointer-events-none"
},
"text": "$t:sirsoft-board.admin.settings.permissions_table.admin_section"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "divide-y divide-gray-200 dark:divide-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "$computed.adminPermissions ?? []",
"item_var": "perm"
},
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes(perm?.[0]) ? 'bg-purple-50 dark:bg-purple-900/20' : 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700'}} flex flex-col gap-2 px-4 py-2.5 transition-colors cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes(perm?.[0]) ? (_local.bulkApplyFields ?? []).filter(f => f !== perm?.[0]) : [...(_local.bulkApplyFields ?? []), perm?.[0]]}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 pointer-events-none"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes(perm?.[0])}}"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-0.5"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.[0]}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-400 dark:text-gray-500"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.[0]}}_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "pl-6"
},
"children": [
{
"id": "perm_tag_admin_{{perm?.[0]}}",
"type": "composite",
"name": "TagInput",
"props": {
"value": "{{(_local.form.basic_defaults.default_board_permissions ?? {})[perm?.[0]] ?? []}}",
"options": "{{(roles?.data?.data ?? []).map(r => ({ value: r.identifier, label: r.name ?? r.identifier }))}}",
"placeholder": "$t:sirsoft-board.admin.settings.permissions_table.select_roles",
"className": "w-full",
"defaultVariant": "purple",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.basic_defaults.default_board_permissions": "{{Object.fromEntries(Object.entries(_local.form.basic_defaults.default_board_permissions ?? {}).map(([k,v]) => k === perm?.[0] ? [k, $event.target.value] : [k, v]))}}",
"hasChanges": true
}
}
]
}
]
}
]
}
]
}
]
},
{
"id": "permissions_user_section",
"type": "basic",
"name": "Div",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 cursor-pointer select-none"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => !k.startsWith('admin.')).every(k => (_local.bulkApplyFields ?? []).includes(k)) ? (_local.bulkApplyFields ?? []).filter(k => k.startsWith('admin.')) : (_local.bulkApplyFields ?? []).concat(Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => !k.startsWith('admin.'))).filter((v, i, a) => a.indexOf(v) === i)}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 pointer-events-none",
"checked": "{{Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => !k.startsWith('admin.')).length > 0 && Object.keys(settings?.data?.basic_defaults?.default_board_permissions ?? {}).filter(k => !k.startsWith('admin.')).every(k => (_local.bulkApplyFields ?? []).includes(k))}}"
}
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "users",
"size": "sm",
"className": "text-blue-500 dark:text-blue-400 pointer-events-none"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-base font-semibold text-gray-800 dark:text-gray-100 pointer-events-none"
},
"text": "$t:sirsoft-board.admin.settings.permissions_table.user_section"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "divide-y divide-gray-200 dark:divide-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "$computed.userPermissions ?? []",
"item_var": "perm"
},
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes(perm?.[0]) ? 'bg-blue-50 dark:bg-blue-900/20' : 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700'}} flex flex-col gap-2 px-4 py-2.5 transition-colors cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes(perm?.[0]) ? (_local.bulkApplyFields ?? []).filter(f => f !== perm?.[0]) : [...(_local.bulkApplyFields ?? []), perm?.[0]]}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 pointer-events-none"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes(perm?.[0])}}"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-0.5"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.[0]}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-400 dark:text-gray-500"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.[0]}}_desc"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "pl-6"
},
"children": [
{
"id": "perm_tag_user_{{perm?.[0]}}",
"type": "composite",
"name": "TagInput",
"props": {
"value": "{{(_local.form.basic_defaults.default_board_permissions ?? {})[perm?.[0]] ?? []}}",
"options": "{{(roles?.data?.data ?? []).map(r => ({ value: r.identifier, label: r.name ?? r.identifier }))}}",
"placeholder": "$t:sirsoft-board.admin.settings.permissions_table.select_roles",
"className": "w-full",
"defaultVariant": "blue",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.basic_defaults.default_board_permissions": "{{Object.fromEntries(Object.entries(_local.form.basic_defaults.default_board_permissions ?? {}).map(([k,v]) => k === perm?.[0] ? [k, $event.target.value] : [k, v]))}}",
"hasChanges": true
}
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
Loaded 100 of 3967 files, more files were not shown because too many files have changed in this diff. Show more