v7.0.0-beta.2 release

This commit is contained in:
HeuJung committed 2026-04-20 20:37:49 +09:00
1 parent 6595fd0eb5
commit fa8eba7a0d
1165 files changed
+107118 -762127

No files matched your search

@@ -4,6 +4,72 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.2] - 2026-04-20
### ⚠️ 업데이트 주의사항
- 파티션 제거 마이그레이션 포함 — 데이터 규모에 따라 시간이 오래 걸릴 수 있습니다
- 게시글 1만 건 이하: 약 1분 이내 / 50만 건 이상: 약 20~30분 이상
- 댓글 90만 건 이상: 약 20~30분 이상
- 강제 종료 시 모듈 상태가 `updating`으로 남을 수 있음 — tinker로 `status`를 `active`로 직접 복구
### Added
- **사이트 내 알림 연동**
- 이메일 외 사이트 내 알림(database 채널) 추가 — 알림센터에서 즉시 확인 가능
- 환경설정 > 알림 탭에서 채널별 ON/OFF 토글 및 템플릿 통합 관리
- 사용자 개인 알림 수신 설정(마이페이지) 반영
- 댓글 직권 처리(블라인드/삭제/복원) 시에도 알림 발송
- 게시글/댓글 처리 알림에 대상 유형 표기 추가
- 신고 누적 자동 블라인드는 직권 처리 알림에서 제외
- 알림 정의 일괄 초기화 기능 — 확인 모달 + 진행 스피너 UX 포함
- 게시판 알림 정의를 코어 리셋 로직에 자동 기여하여 기본값 복원 가능
- 알림 탭은 코어 알림 권한 기준으로 편집 가능 여부를 판단하도록 개선 (모듈 설정 권한과 분리)
- 게시판 유형(BoardType) 사용자 수정 보존 지원
- 게시판 환경설정 SEO 설정 탭 추가
- 게시판 목록/글 목록/게시글 상세 페이지별 메타 제목·설명 직접 설정
- 페이지별 SEO 제공 여부 온/오프 및 캐시 초기화 버튼
- SEO 변수 시스템 적용 — 게시판/게시글 페이지 타입별 변수 정의 및 레이아웃 연동
#### 성능 최적화
- 이전글/다음글 조회를 독립 API로 분리하여 게시글 상세 페이지 초기 로딩 쿼리 감소
- 게시글·댓글 수 등 집계 정보를 사전 계산 컬럼으로 관리하도록 개선 — 목록 조회 시 COUNT 쿼리 제거
- 게시글 본문 검색 및 통합검색에 FULLTEXT 인덱스 적용 — 대용량 데이터에서도 빠른 검색 가능
### Changed
- 코어 최소 요구 버전을 7.0.0-beta.2 로 상향
#### 알림
- 알림 발송 방식을 코어 공통 필터 기반으로 전환하여 이중 발송 제거
- 알림 탭 채널 목록을 코어 공통 API로 교체
- 알림 정의 시더를 인라인 방식으로 전환 — 사용자 수정 자동 보존
- 레거시 메일 템플릿 테이블을 통합 알림 시스템으로 이관
- 이전 업그레이드 스텝에 클래스 부재 시 안전 스킵 처리 추가
- 알림 템플릿 편집 모달 다국어 탭을 동적 생성으로 전환
#### 성능 최적화
- 게시글 목록 조회 시 비활성 게시판 필터링 방식을 개선하여 쿼리 성능 향상
- 인기글 정렬 기준을 조회수 기반으로 단순화하여 인덱스를 활용할 수 있도록 변경
- 글수정 폼 진입 시 불필요한 데이터 조회를 줄여 로딩 쿼리 감소
- 캐시 유지 시간을 설정값 기반으로 관리할 수 있도록 변경
#### 기타
- 공통 캐시 시스템으로 이관 — 모듈 내 모든 캐시 호출을 코어 캐시 인터페이스로 전환하고 키 접두사 통일
- 게시판 목록 데이터 소스를 비인증 접근 허용으로 변경 — SEO 봇 대응
- 게시글 에디터를 확장 포인트(extension_point)로 변환
### Removed
- 직접 발송 리스너 제거 — 코어 알림 시스템으로 대체
- 기본 설정·신고 정책 탭의 알림 채널 선택 제거 — 알림 탭으로 통합
- 알림 채널 관련 컬럼 및 모듈 전용 API 제거 — 코어 공통 API 사용
- 레거시 메일 템플릿 시더 및 관련 다국어 키 제거
## [1.0.0-beta.1] - 2026-04-01
### Changed
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -2,7 +2,7 @@
"_meta": {
"version": "1.0.0",
"description": "게시판 모듈 환경설정",
"categories": ["basic_defaults", "report_policy", "spam_security", "display"]
"categories": ["basic_defaults", "report_policy", "spam_security", "display", "seo", "notifications"]
},
"defaults": {
"basic_defaults": {
@@ -44,8 +44,6 @@
"야동", "포르노", "섹스", "야사", "음란", "성인사이트", "조건만남",
"원나잇", "섹파", "오피", "키스방", "매춘", "성매매", "몸파는"
],
"notify_admin_on_post_channels": ["mail"],
"notify_author_channels": ["mail"],
"default_board_permissions": {
"admin.posts.read": ["admin"],
"admin.posts.write": ["admin"],
@@ -73,9 +71,7 @@
"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"]
"notify_author_on_report_action": true
},
"spam_security": {
"post_cooldown_seconds": 0,
@@ -85,6 +81,23 @@
},
"display": {
"date_display_format": "standard"
},
"seo": {
"meta_boards_title": "{site_name}",
"meta_boards_description": "",
"meta_board_title": "{board_name}",
"meta_board_description": "{board_description}",
"meta_post_title": "{board_name} - {post_title}",
"meta_post_description": "",
"seo_boards": true,
"seo_board": true,
"seo_post_detail": true
},
"notifications": {
"channels": [
{ "id": "mail", "is_active": true, "sort_order": 0 },
{ "id": "database", "is_active": true, "sort_order": 1 }
]
}
},
"frontend_schema": {
@@ -100,6 +113,9 @@
},
"display": {
"expose": false
},
"seo": {
"expose": false
}
}
}
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* board_mail_templates 테이블 삭제.
*
* notification_templates 시스템으로 통합 완료 (#146).
* 운영 환경 데이터 이관은 게시판 모듈 Upgrade_1_0_0_beta_2 에서 수행됨.
*
* @return void
*/
public function up(): void
{
if (! Schema::hasTable('board_mail_templates')) {
return;
}
if (! Schema::hasTable('notification_templates')) {
throw new \RuntimeException(
'board_mail_templates 제거 전 notification_templates 가 존재해야 합니다. '
.'코어 Upgrade_7_0_0_beta_2 + 게시판 Upgrade_1_0_0_beta_2 업그레이드 스텝을 먼저 실행하세요.'
);
}
Schema::drop('board_mail_templates');
}
/**
* board_mail_templates 테이블 복원.
*
* 운영 데이터 복구는 별도 백업에서 수행해야 합니다.
*
* @return void
*/
public function down(): void
{
if (Schema::hasTable('board_mail_templates')) {
return;
}
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('다국어 제목');
$table->mediumText('body')->comment('다국어 본문');
$table->text('variables')->nullable()->comment('사용 가능 변수 메타데이터');
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
$table->timestamps();
});
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* board_types 테이블에 user_overrides 컬럼 추가.
*
* 사용자가 수정한 필드명 목록을 저장하여 시더 재실행 시 보존합니다.
*
* @return void
*/
public function up(): void
{
if (! Schema::hasTable('board_types') || Schema::hasColumn('board_types', 'user_overrides')) {
return;
}
Schema::table('board_types', function (Blueprint $table) {
$table->text('user_overrides')->nullable()
->comment('유저가 수정한 필드명 목록 (예: ["name"])');
});
}
/**
* user_overrides 컬럼 삭제.
*
* @return void
*/
public function down(): void
{
if (! Schema::hasTable('board_types') || ! Schema::hasColumn('board_types', 'user_overrides')) {
return;
}
Schema::table('board_types', function (Blueprint $table) {
$table->dropColumn('user_overrides');
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* boards 테이블에서 알림 채널 컬럼 삭제.
*/
public function up(): void
{
Schema::table('boards', function (Blueprint $table) {
if (Schema::hasColumn('boards', 'notify_author_channels')) {
$table->dropColumn('notify_author_channels');
}
if (Schema::hasColumn('boards', 'notify_admin_on_post_channels')) {
$table->dropColumn('notify_admin_on_post_channels');
}
});
}
/**
* boards 테이블에 알림 채널 컬럼 복원.
*/
public function down(): void
{
Schema::table('boards', function (Blueprint $table) {
if (! Schema::hasColumn('boards', 'notify_author_channels')) {
$table->text('notify_author_channels')->nullable()->after('notify_admin_on_post')->comment('작성자 알림 채널 (배열, 예: ["mail", "database"])');
}
if (! Schema::hasColumn('boards', 'notify_admin_on_post_channels')) {
$table->text('notify_admin_on_post_channels')->nullable()->after('notify_author_channels')->comment('관리자 알림 채널 (배열, 예: ["mail", "database"])');
}
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* board_posts, board_comments, board_attachments 3개 테이블의 파티션 제거.
* 복합 PK (id, board_id) → id 단독 PK로 변경.
* STEP 2의 복합 인덱스 적용을 위한 선행 작업.
*/
public function up(): void
{
// ℹ️ 마이그레이션 파일에서는 DB::getTablePrefix() 사용 (Laravel 표준)
$prefix = DB::getTablePrefix();
DB::statement("ALTER TABLE {$prefix}board_posts REMOVE PARTITIONING");
DB::statement("ALTER TABLE {$prefix}board_posts DROP PRIMARY KEY, ADD PRIMARY KEY (id)");
DB::statement("ALTER TABLE {$prefix}board_comments REMOVE PARTITIONING");
DB::statement("ALTER TABLE {$prefix}board_comments DROP PRIMARY KEY, ADD PRIMARY KEY (id)");
DB::statement("ALTER TABLE {$prefix}board_attachments REMOVE PARTITIONING");
DB::statement("ALTER TABLE {$prefix}board_attachments DROP PRIMARY KEY, ADD PRIMARY KEY (id)");
}
/**
* Reverse the migrations.
*
* 파티션 복원은 파티션별 데이터 재배치가 필요하므로 자동 롤백이 불가합니다.
*/
public function down(): void
{
throw new \RuntimeException(
'board_posts/board_comments/board_attachments 파티션 제거는 롤백이 불가합니다. '
. '복원이 필요한 경우 DBA가 수동으로 수행해야 합니다.'
);
}
};
@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 게시글/댓글 테이블에 카운팅 컬럼 추가
*
* 기존 데이터 동기화는 Upgrade_1_0_0_beta_2 업그레이드 스텝에서 chunk 처리로 수행
*/
public function up(): void
{
// board_posts: replies_count, comments_count, attachments_count
Schema::table('board_posts', function (Blueprint $table) {
if (! Schema::hasColumn('board_posts', 'replies_count')) {
$table->unsignedInteger('replies_count')
->default(0)
->after('view_count')
->comment('답글 수');
}
if (! Schema::hasColumn('board_posts', 'comments_count')) {
$table->unsignedInteger('comments_count')
->default(0)
->after('replies_count')
->comment('댓글 수');
}
if (! Schema::hasColumn('board_posts', 'attachments_count')) {
$table->unsignedInteger('attachments_count')
->default(0)
->after('comments_count')
->comment('첨부파일 수');
}
});
// board_comments: replies_count
Schema::table('board_comments', function (Blueprint $table) {
if (! Schema::hasColumn('board_comments', 'replies_count')) {
$table->unsignedInteger('replies_count')
->default(0)
->after('status')
->comment('대댓글 수');
}
});
}
/**
* 마이그레이션 롤백
*/
public function down(): void
{
if (Schema::hasTable('board_posts')) {
$columns = Schema::getColumnListing('board_posts');
Schema::table('board_posts', function (Blueprint $table) use ($columns) {
if (in_array('replies_count', $columns)) {
$table->dropColumn('replies_count');
}
if (in_array('comments_count', $columns)) {
$table->dropColumn('comments_count');
}
if (in_array('attachments_count', $columns)) {
$table->dropColumn('attachments_count');
}
});
}
if (Schema::hasTable('board_comments')) {
$columns = Schema::getColumnListing('board_comments');
Schema::table('board_comments', function (Blueprint $table) use ($columns) {
if (in_array('replies_count', $columns)) {
$table->dropColumn('replies_count');
}
});
}
}
};
@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* boards 테이블에 카운팅 컬럼 추가 (posts_count, comments_count)
*
* 기존 데이터 동기화는 Upgrade_1_0_0_beta_2 업그레이드 스텝에서 수행
*/
public function up(): void
{
Schema::table('boards', function (Blueprint $table) {
if (! Schema::hasColumn('boards', 'posts_count')) {
$table->unsignedInteger('posts_count')
->default(0)
->after('is_active')
->comment('게시글 수');
}
if (! Schema::hasColumn('boards', 'comments_count')) {
$table->unsignedInteger('comments_count')
->default(0)
->after('posts_count')
->comment('댓글 수');
}
});
}
/**
* 마이그레이션 롤백
*/
public function down(): void
{
if (Schema::hasTable('boards')) {
$columns = Schema::getColumnListing('boards');
Schema::table('boards', function (Blueprint $table) use ($columns) {
if (in_array('posts_count', $columns)) {
$table->dropColumn('posts_count');
}
if (in_array('comments_count', $columns)) {
$table->dropColumn('comments_count');
}
});
}
}
};
@@ -0,0 +1,360 @@
<?php
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* board_posts / board_comments / board_attachments 인덱스 전체 정비
*
* 전체 53개 쿼리 패턴 전수조사 + EXPLAIN 검증을 거쳐
* 복합 인덱스 추가 · 통합 · 중복/미사용 인덱스 제거를 일괄 적용합니다.
*
* ## board_posts
*
* 추가 (10개):
* - idx_board_posts_board_parent (board_id, parent_id) 답글 트리 조회, getAdjacentPosts index_merge
* - idx_board_posts_board_category (board_id, category, created_at) 카테고리 필터+정렬
* - idx_board_posts_board_view_count (board_id, view_count) 인기글 조회수 정렬, getPopularPosts USE INDEX
* - idx_board_posts_user_activity (user_id, deleted_at, board_id, created_at) 마이페이지 COUNT 커버링
* - idx_board_posts_user_created (user_id, deleted_at, created_at) 마이페이지 SELECT + ORDER BY created_at (Filesort 제거)
* - idx_board_posts_user_board_stats (user_id, board_id, comments_count, view_count) 사용자 활동 통계 SUM 커버링
* - idx_board_posts_list_count (board_id, is_notice, parent_id, deleted_at, created_at) 목록 페이지네이션 커버링 + 정렬
* - idx_board_posts_adjacent (board_id, status, is_notice, parent_id, deleted_at, created_at, id) 이전/다음글 커버링 인덱스
* - idx_board_posts_user_status (user_id, status) 사용자 프로필 공개 통계
* - ft_board_posts_title_content FULLTEXT(title, content) 제목+본문 FULLTEXT 검색
*
* 제거 (11개 — 중복·미사용):
* - g7_board_posts_category_index (category) → idx_board_posts_board_category에 포함
* - g7_board_posts_view_count_index (view_count) → idx_board_posts_board_view_count에 포함
* - g7_board_posts_parent_id_index (parent_id) → idx_board_posts_board_parent 좌측 프리픽스
* - g7_board_posts_user_id_index (user_id) → idx_board_posts_user_activity 좌측 프리픽스
* - idx_board_notice (board_id, is_notice) → idx_board_posts_list_count 좌측 프리픽스
* - idx_board_status (board_id, status) → idx_board_posts_board_status_created 좌측 프리픽스
* - idx_user_views (user_id, view_count) → 사용 쿼리 없음
* - idx_user_created (user_id, created_at) → idx_board_posts_user_activity로 통합
* - idx_board_posts_user_deleted (user_id, deleted_at) → idx_board_posts_user_activity로 통합
* - idx_board_posts_recent_lookup (parent_id, status, deleted_at, created_at) → board_id 누락으로 풀스캔 유발, idx_board_posts_board_status_created가 대체
* - idx_board_posts_user_board_views (user_id, board_id, view_count) → idx_board_posts_user_board_stats로 대체
*
* ## board_comments
*
* 추가 (5개):
* - idx_board_comments_board_parent (board_id, parent_id) 답글 조회, softDeleteByBoardId, boardCmtCntSync
* - idx_board_comments_post_created (board_id, post_id, created_at) 댓글 목록 정렬 + softDeleteByPostId (idx_board_post 대체)
* - idx_board_comments_user_board (user_id, board_id) 마이페이지 활동 통계
* - idx_board_comments_post_deleted_created (board_id, post_id, deleted_at, created_at) 댓글 목록 deleted_at 필터링 인덱스 레벨 처리
* - idx_board_comments_user_status (user_id, status) 사용자 프로필 공개 통계
*
* 제거 (4개 — 중복):
* - idx_board_post (board_id, post_id) → idx_board_comments_post_created 좌측 프리픽스
* - idx_board_status (board_id, status) → 사용 쿼리 없음
* - idx_board_created (board_id, created_at) → 사용 쿼리 없음
* - g7_board_comments_user_id_index (user_id) → idx_user_post 좌측 프리픽스
*
* ## board_attachments
*
* 추가 (1개):
* - idx_board_attachments_post_id (post_id) 썸네일 eager loading WHERE post_id IN (...)
*/
return new class extends Migration
{
/**
* 인덱스를 추가하고 중복·미사용 인덱스를 제거합니다.
*
* @return void
*/
public function up(): void
{
// ── board_posts: 인덱스 추가 ──────────────────────────────────────────
Schema::table('board_posts', function (Blueprint $table) {
if (! $this->hasIndex('board_posts', 'idx_board_posts_board_parent')) {
// 답글 트리 조회 (loadAllDescendantReplies), getAdjacentPosts index_merge
$table->index(['board_id', 'parent_id'], 'idx_board_posts_board_parent');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_board_category')) {
// 카테고리 필터 + created_at 정렬
$table->index(['board_id', 'category', 'created_at'], 'idx_board_posts_board_category');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_board_view_count')) {
// 인기글 조회수 정렬 (getPopularPosts USE INDEX)
$table->index(['board_id', 'view_count'], 'idx_board_posts_board_view_count');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_user_activity')) {
// 마이페이지 COUNT 커버링: WHERE user_id + deleted_at IS NULL + board_id NOT IN
$table->index(['user_id', 'deleted_at', 'board_id', 'created_at'], 'idx_board_posts_user_activity');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_user_created')) {
// 마이페이지 SELECT + ORDER BY created_at: Filesort 제거
$table->index(['user_id', 'deleted_at', 'created_at'], 'idx_board_posts_user_created');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_user_board_stats')) {
// 사용자 활동 통계: SUM(comments_count) + SUM(view_count) 커버링 인덱스
$table->index(['user_id', 'board_id', 'comments_count', 'view_count'], 'idx_board_posts_user_board_stats');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_list_count')) {
// 목록 페이지네이션 커버링 + 정렬 인덱스
$table->index(
['board_id', 'is_notice', 'parent_id', 'deleted_at', 'created_at'],
'idx_board_posts_list_count'
);
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_adjacent')) {
// 이전/다음글 쿼리 커버링 인덱스
// 등치 조건 5개 (board_id, status, is_notice, parent_id, deleted_at) + 정렬 (created_at, id)
$table->index(
['board_id', 'status', 'is_notice', 'parent_id', 'deleted_at', 'created_at', 'id'],
'idx_board_posts_adjacent'
);
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_user_status')) {
// 사용자 프로필 공개 통계 — user_id + status 필터링
$table->index(['user_id', 'status'], 'idx_board_posts_user_status');
}
});
// ── board_posts: FULLTEXT 인덱스 추가 ────────────────────────────────
$indexes = array_column(Schema::getIndexes('board_posts'), 'name');
if (! in_array('ft_board_posts_title_content', $indexes)) {
DatabaseFulltextEngine::addFulltextIndex('board_posts', 'ft_board_posts_title_content', ['title', 'content']);
}
// ── board_posts: 중복·미사용 인덱스 제거 (11개) ───────────────────────
Schema::table('board_posts', function (Blueprint $table) {
// (category) 단일 → idx_board_posts_board_category에 포함
if ($this->hasIndex('board_posts', 'g7_board_posts_category_index')) {
$table->dropIndex('g7_board_posts_category_index');
}
// (view_count) 단일 → idx_board_posts_board_view_count에 포함
if ($this->hasIndex('board_posts', 'g7_board_posts_view_count_index')) {
$table->dropIndex('g7_board_posts_view_count_index');
}
// (parent_id) 단일 → idx_board_posts_board_parent 좌측 프리픽스
if ($this->hasIndex('board_posts', 'g7_board_posts_parent_id_index')) {
$table->dropIndex('g7_board_posts_parent_id_index');
}
// (user_id) 단일 → idx_board_posts_user_activity 좌측 프리픽스
if ($this->hasIndex('board_posts', 'g7_board_posts_user_id_index')) {
$table->dropIndex('g7_board_posts_user_id_index');
}
// (board_id, is_notice) → idx_board_posts_list_count 좌측 프리픽스
if ($this->hasIndex('board_posts', 'idx_board_notice')) {
$table->dropIndex('idx_board_notice');
}
// (board_id, status) → idx_board_posts_board_status_created 좌측 프리픽스
if ($this->hasIndex('board_posts', 'idx_board_status')) {
$table->dropIndex('idx_board_status');
}
// (user_id, view_count) → 사용 쿼리 없음
if ($this->hasIndex('board_posts', 'idx_user_views')) {
$table->dropIndex('idx_user_views');
}
// (user_id, created_at) → idx_board_posts_user_activity로 통합
if ($this->hasIndex('board_posts', 'idx_user_created')) {
$table->dropIndex('idx_user_created');
}
// (user_id, deleted_at) → idx_board_posts_user_activity로 통합
if ($this->hasIndex('board_posts', 'idx_board_posts_user_deleted')) {
$table->dropIndex('idx_board_posts_user_deleted');
}
// (parent_id, status, deleted_at, created_at) → board_id 누락으로 풀스캔 유발, idx_board_posts_board_status_created가 대체
if ($this->hasIndex('board_posts', 'idx_board_posts_recent_lookup')) {
$table->dropIndex('idx_board_posts_recent_lookup');
}
// (user_id, board_id, view_count) → idx_board_posts_user_board_stats로 대체
if ($this->hasIndex('board_posts', 'idx_board_posts_user_board_views')) {
$table->dropIndex('idx_board_posts_user_board_views');
}
});
// ── board_comments: 인덱스 추가 (5개) ────────────────────────────────
Schema::table('board_comments', function (Blueprint $table) {
if (! $this->hasIndex('board_comments', 'idx_board_comments_board_parent')) {
// 답글 조회, softDeleteByBoardId, boardCmtCntSync (board_id + parent_id)
$table->index(['board_id', 'parent_id'], 'idx_board_comments_board_parent');
}
if (! $this->hasIndex('board_comments', 'idx_board_comments_post_created')) {
// 댓글 목록 정렬 + softDeleteByPostId (idx_board_post 대체)
$table->index(['board_id', 'post_id', 'created_at'], 'idx_board_comments_post_created');
}
if (! $this->hasIndex('board_comments', 'idx_board_comments_user_board')) {
// 마이페이지 활동 통계 (getUserActivityStats)
$table->index(['user_id', 'board_id'], 'idx_board_comments_user_board');
}
if (! $this->hasIndex('board_comments', 'idx_board_comments_post_deleted_created')) {
// 댓글 목록 조회 시 deleted_at 필터링 인덱스 레벨 처리
$table->index(
['board_id', 'post_id', 'deleted_at', 'created_at'],
'idx_board_comments_post_deleted_created'
);
}
if (! $this->hasIndex('board_comments', 'idx_board_comments_user_status')) {
// 사용자 프로필 공개 통계 — user_id + status 필터링
$table->index(['user_id', 'status'], 'idx_board_comments_user_status');
}
});
// ── board_comments: 중복 인덱스 제거 (4개) ───────────────────────────
Schema::table('board_comments', function (Blueprint $table) {
// (board_id, post_id) → idx_board_comments_post_created 좌측 프리픽스
if ($this->hasIndex('board_comments', 'idx_board_post')) {
$table->dropIndex('idx_board_post');
}
// (board_id, status) → 사용 쿼리 없음
if ($this->hasIndex('board_comments', 'idx_board_status')) {
$table->dropIndex('idx_board_status');
}
// (board_id, created_at) → 사용 쿼리 없음
if ($this->hasIndex('board_comments', 'idx_board_created')) {
$table->dropIndex('idx_board_created');
}
// (user_id) 단일 → idx_user_post (user_id, post_id) 좌측 프리픽스
if ($this->hasIndex('board_comments', 'g7_board_comments_user_id_index')) {
$table->dropIndex('g7_board_comments_user_id_index');
}
});
// ── board_attachments: 인덱스 추가 (1개) ─────────────────────────────
Schema::table('board_attachments', function (Blueprint $table) {
if (! $this->hasIndex('board_attachments', 'idx_board_attachments_post_id')) {
// 썸네일 eager loading: WHERE post_id IN (...) AND mime_type LIKE 'image/%'
$table->index('post_id', 'idx_board_attachments_post_id');
}
});
}
/**
* 인덱스를 원상 복구합니다.
*
* @return void
*/
public function down(): void
{
// ── board_posts: 추가한 인덱스 제거 ─────────────────────────────────
Schema::table('board_posts', function (Blueprint $table) {
foreach ([
'idx_board_posts_board_parent',
'idx_board_posts_board_category',
'idx_board_posts_board_view_count',
'idx_board_posts_user_activity',
'idx_board_posts_user_created',
'idx_board_posts_user_board_stats',
'idx_board_posts_list_count',
'idx_board_posts_adjacent',
'idx_board_posts_user_status',
] as $index) {
if ($this->hasIndex('board_posts', $index)) {
$table->dropIndex($index);
}
}
});
// ── board_posts: FULLTEXT 인덱스 제거 ────────────────────────────────
$indexes = array_column(Schema::getIndexes('board_posts'), 'name');
if (in_array('ft_board_posts_title_content', $indexes)) {
Schema::table('board_posts', function (Blueprint $table) {
$table->dropIndex('ft_board_posts_title_content');
});
}
// ── board_posts: 제거했던 인덱스 복원 ────────────────────────────────
Schema::table('board_posts', function (Blueprint $table) {
if (! $this->hasIndex('board_posts', 'g7_board_posts_category_index')) {
$table->index('category', 'g7_board_posts_category_index');
}
if (! $this->hasIndex('board_posts', 'g7_board_posts_view_count_index')) {
$table->index('view_count', 'g7_board_posts_view_count_index');
}
if (! $this->hasIndex('board_posts', 'g7_board_posts_parent_id_index')) {
$table->index('parent_id', 'g7_board_posts_parent_id_index');
}
if (! $this->hasIndex('board_posts', 'g7_board_posts_user_id_index')) {
$table->index('user_id', 'g7_board_posts_user_id_index');
}
if (! $this->hasIndex('board_posts', 'idx_board_notice')) {
$table->index(['board_id', 'is_notice'], 'idx_board_notice');
}
if (! $this->hasIndex('board_posts', 'idx_board_status')) {
$table->index(['board_id', 'status'], 'idx_board_status');
}
// idx_user_views — 사용 쿼리 없음이므로 down()에서 복원하지 않음
if (! $this->hasIndex('board_posts', 'idx_user_created')) {
$table->index(['user_id', 'created_at'], 'idx_user_created');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_user_deleted')) {
$table->index(['user_id', 'deleted_at'], 'idx_board_posts_user_deleted');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_recent_lookup')) {
$table->index(['parent_id', 'status', 'deleted_at', 'created_at'], 'idx_board_posts_recent_lookup');
}
if (! $this->hasIndex('board_posts', 'idx_board_posts_user_board_views')) {
$table->index(['user_id', 'board_id', 'view_count'], 'idx_board_posts_user_board_views');
}
});
// ── board_comments: 추가한 인덱스 제거 ──────────────────────────────
Schema::table('board_comments', function (Blueprint $table) {
foreach ([
'idx_board_comments_board_parent',
'idx_board_comments_post_created',
'idx_board_comments_user_board',
'idx_board_comments_post_deleted_created',
'idx_board_comments_user_status',
] as $index) {
if ($this->hasIndex('board_comments', $index)) {
$table->dropIndex($index);
}
}
});
// ── board_comments: 제거했던 인덱스 복원 ─────────────────────────────
Schema::table('board_comments', function (Blueprint $table) {
if (! $this->hasIndex('board_comments', 'idx_board_post')) {
$table->index(['board_id', 'post_id'], 'idx_board_post');
}
if (! $this->hasIndex('board_comments', 'idx_board_status')) {
$table->index(['board_id', 'status'], 'idx_board_status');
}
if (! $this->hasIndex('board_comments', 'idx_board_created')) {
$table->index(['board_id', 'created_at'], 'idx_board_created');
}
if (! $this->hasIndex('board_comments', 'g7_board_comments_user_id_index')) {
$table->index('user_id', 'g7_board_comments_user_id_index');
}
});
// ── board_attachments: 추가한 인덱스 제거 ────────────────────────────
Schema::table('board_attachments', function (Blueprint $table) {
if ($this->hasIndex('board_attachments', 'idx_board_attachments_post_id')) {
$table->dropIndex('idx_board_attachments_post_id');
}
});
}
/**
* 인덱스 존재 여부를 확인합니다.
*
* @param string $table 테이블명
* @param string $indexName 인덱스명
* @return bool
*/
private function hasIndex(string $table, string $indexName): bool
{
$indexes = Schema::getIndexes($table);
return collect($indexes)->contains(fn ($index) => $index['name'] === $indexName);
}
};
@@ -1,336 +0,0 @@
<?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,483 @@
<?php
namespace Modules\Sirsoft\Board\Database\Seeders;
use App\Extension\Helpers\NotificationSyncHelper;
use Illuminate\Database\Seeder;
class BoardNotificationDefinitionSeeder extends Seeder
{
/**
* 게시판 알림 정의 및 템플릿을 시딩합니다.
*
* 데이터 정합성 (upsert + stale cleanup) 은 NotificationSyncHelper 에 위임하며,
* 사용자 수정(user_overrides) 은 helper 내부 HasUserOverrides trait 를 통해 보존됩니다.
*
* @return void
*/
public function run(): void
{
$this->command?->info('게시판 알림 정의 시딩 시작...');
$helper = app(NotificationSyncHelper::class);
$definitions = $this->getDefaultDefinitions();
$definedTypes = [];
foreach ($definitions as $data) {
$definition = $helper->syncDefinition($data);
$definedTypes[] = $definition->type;
$definedChannels = [];
foreach ($data['templates'] as $template) {
$helper->syncTemplate($definition->id, $template);
$definedChannels[] = $template['channel'];
}
// 완전 동기화: seeder 에서 제거된 channel 의 template 삭제
$helper->cleanupStaleTemplates($definition->id, $definedChannels);
$this->command?->info(" - {$data['type']} 알림 정의 등록 완료");
}
// 완전 동기화: seeder 에서 제거된 게시판 definition 삭제 (cascade 로 template 도 정리)
$helper->cleanupStaleDefinitions('module', 'sirsoft-board', $definedTypes);
$this->command?->info('게시판 알림 정의 시딩 완료 ('.count($definitions).'종)');
}
/**
* 게시판 기본 알림 정의 데이터를 반환합니다.
*
* @return array
*/
public function getDefaultDefinitions(): array
{
return [
$this->newCommentDefinition(),
$this->replyCommentDefinition(),
$this->postReplyDefinition(),
$this->postActionDefinition(),
$this->newPostAdminDefinition(),
$this->reportReceivedAdminDefinition(),
$this->reportActionDefinition(),
];
}
/**
* 새 댓글 알림 정의.
*
* @return array
*/
private function newCommentDefinition(): array
{
return [
'type' => 'new_comment',
'hook_prefix' => 'sirsoft-board',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => '새 댓글 알림', 'en' => 'New Comment Notification'],
'description' => ['ko' => '게시글에 새 댓글이 작성되면 게시글 작성자에게 발송', 'en' => 'Sent to post author when a new comment is posted'],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-board.comment.after_create'],
'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'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'related_user', 'relation' => 'post_author', 'exclude_trigger_user' => true]],
'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>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'related_user', 'relation' => 'post_author', 'exclude_trigger_user' => true]],
'subject' => ['ko' => '게시글에 새 댓글이 달렸습니다', 'en' => 'New comment on your post'],
'body' => ['ko' => '{comment_author}님이 \'{board_name}\' 게시글 \'{post_title}\'에 댓글을 남겼습니다.', 'en' => '{comment_author} commented on your post \'{post_title}\' in \'{board_name}\'.'],
'click_url' => '{post_url}',
],
],
];
}
/**
* 대댓글 알림 정의.
*
* @return array
*/
private function replyCommentDefinition(): array
{
return [
'type' => 'reply_comment',
'hook_prefix' => 'sirsoft-board',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => '대댓글 알림', 'en' => 'Reply Comment Notification'],
'description' => ['ko' => '댓글에 대댓글이 작성되면 댓글 작성자에게 발송', 'en' => 'Sent to comment author when a reply is posted'],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-board.comment.after_create'],
'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'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'related_user', 'relation' => 'parent_comment_author', 'exclude_trigger_user' => true]],
'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>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'related_user', 'relation' => 'parent_comment_author', 'exclude_trigger_user' => true]],
'subject' => ['ko' => '댓글에 답글이 달렸습니다', 'en' => 'New reply to your comment'],
'body' => ['ko' => '{comment_author}님이 \'{board_name}\'의 댓글에 답글을 남겼습니다.', 'en' => '{comment_author} replied to your comment in \'{board_name}\'.'],
'click_url' => '{post_url}',
],
],
];
}
/**
* 답변글 알림 정의.
*
* @return array
*/
private function postReplyDefinition(): array
{
return [
'type' => 'post_reply',
'hook_prefix' => 'sirsoft-board',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => '답변글 알림', 'en' => 'Post Reply Notification'],
'description' => ['ko' => '게시글에 답변글이 작성되면 원글 작성자에게 발송', 'en' => 'Sent to original post author when a reply post is created'],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-board.post.after_create'],
'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'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'related_user', 'relation' => 'original_post_author', 'exclude_trigger_user' => true]],
'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>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'related_user', 'relation' => 'original_post_author', 'exclude_trigger_user' => true]],
'subject' => ['ko' => '게시글에 답변이 등록되었습니다', 'en' => 'A reply has been posted to your post'],
'body' => ['ko' => '\'{board_name}\'의 게시글 \'{post_title}\'에 답변이 등록되었습니다.', 'en' => 'A reply has been posted to your post \'{post_title}\' in \'{board_name}\'.'],
'click_url' => '{post_url}',
],
],
];
}
/**
* 게시글 처리 알림 정의.
*
* @return array
*/
private function postActionDefinition(): array
{
return [
'type' => 'post_action',
'hook_prefix' => 'sirsoft-board',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => '게시글/댓글 처리 알림', 'en' => 'Content Action Notification'],
'description' => ['ko' => '게시글/댓글 블라인드/삭제/복원 시 작성자에게 발송', 'en' => 'Sent to author on blind/delete/restore actions'],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-board.post.after_blind', 'sirsoft-board.post.after_delete', 'sirsoft-board.post.after_restore', 'sirsoft-board.comment.after_blind', 'sirsoft-board.comment.after_delete', 'sirsoft-board.comment.after_restore'],
'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'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'related_user', 'relation' => 'post_author']],
'subject' => [
'ko' => '[{board_name}] \'{post_title}\' {target_type}이(가) {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>{post_title}</strong>" {target_type}이(가) 관리자에 의해 <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 {target_type} "<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>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'related_user', 'relation' => 'post_author']],
'subject' => ['ko' => '{target_type}이(가) 처리되었습니다', 'en' => 'Your {target_type} has been actioned'],
'body' => ['ko' => '\'{board_name}\'의 \'{post_title}\' {target_type}이(가) {action_type} 처리되었습니다.', 'en' => 'Your {target_type} \'{post_title}\' in \'{board_name}\' has been {action_type}.'],
'click_url' => '{post_url}',
],
],
];
}
/**
* 관리자 새 게시글 알림 정의.
*
* @return array
*/
private function newPostAdminDefinition(): array
{
return [
'type' => 'new_post_admin',
'hook_prefix' => 'sirsoft-board',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => '새 게시글 관리자 알림', 'en' => 'New Post Admin Notification'],
'description' => ['ko' => '새 게시글 작성 시 관리자에게 발송', 'en' => 'Sent to admin when a new post is created'],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-board.post.after_create'],
'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'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'related_user', 'relation' => 'board_managers', 'exclude_trigger_user' => true]],
'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>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'related_user', 'relation' => 'board_managers', 'exclude_trigger_user' => true]],
'subject' => ['ko' => '새 게시글이 등록되었습니다', 'en' => 'New post registered'],
'body' => ['ko' => '{post_author}님이 \'{board_name}\'에 새 게시글 \'{post_title}\'을 등록했습니다.', 'en' => '{post_author} posted \'{post_title}\' in \'{board_name}\'.'],
'click_url' => '{post_url}',
],
],
];
}
/**
* 신고 접수 관리자 알림 정의.
*
* @return array
*/
private function reportReceivedAdminDefinition(): array
{
return [
'type' => 'report_received_admin',
'hook_prefix' => 'sirsoft-board',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => '신고 접수 관리자 알림', 'en' => 'Report Received Admin Notification'],
'description' => ['ko' => '신고 접수 시 관리자에게 발송', 'en' => 'Sent to admin when a report is received'],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-board.report.after_create'],
'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'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'role', 'value' => 'admin', 'exclude_trigger_user' => true]],
'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>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'role', 'value' => 'admin', 'exclude_trigger_user' => true]],
'subject' => ['ko' => '신고가 접수되었습니다', 'en' => 'New report received'],
'body' => ['ko' => '\'{board_name}\'의 {target_type} \'{post_title}\'에 대한 신고({reason_type})가 접수되었습니다.', 'en' => 'A report ({reason_type}) on {target_type} \'{post_title}\' in \'{board_name}\' has been received.'],
'click_url' => '{report_url}',
],
],
];
}
/**
* 신고 처리 결과 알림 정의.
*
* @return array
*/
private function reportActionDefinition(): array
{
return [
'type' => 'report_action',
'hook_prefix' => 'sirsoft-board',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => '신고 처리 결과 알림', 'en' => 'Report Action Notification'],
'description' => ['ko' => '신고 처리 완료 시 게시글 작성자에게 발송', 'en' => 'Sent to post author when a report action is taken'],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-board.post.after_blind', 'sirsoft-board.post.after_delete', 'sirsoft-board.post.after_restore', 'sirsoft-board.comment.after_blind', 'sirsoft-board.comment.after_delete', 'sirsoft-board.comment.after_restore'],
'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'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'related_user', 'relation' => 'post_author']],
'subject' => [
'ko' => '[{board_name}] \'{post_title}\' {target_type}이(가) {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>{post_title}</strong>" {target_type}이(가) 신고 처리에 의해 <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 {target_type} "<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>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'related_user', 'relation' => 'post_author']],
'subject' => ['ko' => '신고 처리 결과 안내', 'en' => 'Report action result'],
'body' => ['ko' => '\'{board_name}\'의 \'{post_title}\' {target_type}이(가) {action_type} 처리되었습니다.', 'en' => 'Your {target_type} \'{post_title}\' in \'{board_name}\' has been {action_type}.'],
'click_url' => '{post_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>';
}
}
@@ -2,6 +2,7 @@
namespace Modules\Sirsoft\Board\Database\Seeders;
use App\Extension\Helpers\GenericEntitySyncHelper;
use Illuminate\Database\Seeder;
use Modules\Sirsoft\Board\Models\BoardType;
@@ -10,6 +11,8 @@ use Modules\Sirsoft\Board\Models\BoardType;
*
* 기본 3개 유형(basic, gallery, card)을 생성합니다.
* module.php getSeeders()에서 직접 등록됩니다.
*
* GenericEntitySyncHelper 기반 upsert + stale cleanup 패턴.
*/
class BoardTypeSeeder extends Seeder
{
@@ -38,15 +41,30 @@ class BoardTypeSeeder extends Seeder
*/
public function run(): void
{
$helper = app(GenericEntitySyncHelper::class);
$definedSlugs = [];
foreach (self::DEFAULT_BOARD_TYPES as $boardTypeData) {
if (BoardType::where('slug', $boardTypeData['slug'])->exists()) {
$this->command->warn(" 게시판 유형 '{$boardTypeData['slug']}' 이미 존재합니다. 건너뜁니다.");
$existing = BoardType::where('slug', $boardTypeData['slug'])->exists();
continue;
$helper->sync(
BoardType::class,
['slug' => $boardTypeData['slug']],
['name' => $boardTypeData['name']],
);
$definedSlugs[] = $boardTypeData['slug'];
if ($existing) {
$this->command->info(" 게시판 유형 '{$boardTypeData['slug']}' 동기화 (사용자 수정 보존).");
} else {
$this->command->info(" 게시판 유형 '{$boardTypeData['slug']}' 생성 완료.");
}
}
BoardType::create($boardTypeData);
$this->command->info(" 게시판 유형 '{$boardTypeData['slug']}' 생성 완료.");
// 완전 동기화: seeder 에서 제거된 유형 삭제 (user_overrides 무관)
$deleted = $helper->cleanupStale(BoardType::class, [], 'slug', $definedSlugs);
if ($deleted > 0) {
$this->command->warn(" stale 게시판 유형 {$deleted}건 삭제");
}
}
}
@@ -12,7 +12,7 @@ use Illuminate\Database\Seeder;
* 설치 필수 시더는 항상 실행되며, 샘플 시더는 --sample 옵션 시에만 실행됩니다.
*
* 설치 시더:
* - InstallSeeder (BoardMailTemplateSeeder 포함)
* - InstallSeeder (BoardNotificationDefinitionSeeder 포함)
* - BoardTypeSeeder
*
* 샘플 시더 (--sample 옵션 시):
@@ -46,6 +46,7 @@ class DatabaseSeeder extends Seeder
$this->call([
InstallSeeder::class,
BoardTypeSeeder::class,
BoardNotificationDefinitionSeeder::class,
]);
$this->command->info('');
@@ -69,13 +70,8 @@ class DatabaseSeeder extends Seeder
$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] 활동 로그 샘플 생성');
// 4. 활동 로그 샘플 (모든 샘플 데이터 생성 후 마지막에 실행)
$this->command->info('[4/4] 활동 로그 샘플 생성');
$this->call(ActivityLogSampleSeeder::class);
$this->command->info('');
}
@@ -8,7 +8,7 @@ use Illuminate\Database\Seeder;
* 게시판 모듈 설치 시더
*
* 모듈 설치 시 실행되는 시더입니다.
* 테스트용 더미 데이터는 포함하지 않습니다.
* 기본 알림 정의 7종을 생성합니다.
*
* 테스트 데이터가 필요한 경우 DatabaseSeeder를 수동으로 실행하세요:
* php artisan module:seed sirsoft-board
@@ -20,6 +20,6 @@ class InstallSeeder extends Seeder
*/
public function run(): void
{
$this->call(BoardMailTemplateSeeder::class);
$this->call(BoardNotificationDefinitionSeeder::class);
}
}
@@ -1,141 +0,0 @@
<?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)];
}
}
@@ -71,10 +71,8 @@ class BoardSampleSeeder extends Seeder
'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']],
@@ -112,9 +110,7 @@ class BoardSampleSeeder extends Seeder
'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', '도박', '대출'],
@@ -153,7 +149,6 @@ class BoardSampleSeeder extends Seeder
'comment_order' => 'DESC',
'max_comment_depth' => 3,
'notify_author' => true,
'notify_author_channels' => ['database'],
'notify_admin_on_post' => false,
// 기본값 그대로 — 회원만 글쓰기/댓글, 전체 읽기/다운로드
],
@@ -217,9 +212,7 @@ class BoardSampleSeeder extends Seeder
'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 추가
// 비밀글을 작성자 외 로그인 회원도 읽을 수 있는 케이스 (고객센터 담당자 등)
@@ -253,7 +246,6 @@ class BoardSampleSeeder extends Seeder
'per_page' => 20,
'new_display_hours' => 24,
'notify_author' => true,
'notify_author_channels' => ['database'],
'notify_admin_on_post' => false,
// 금지어: 기본 45개 대신 스팸 특화 커스텀
'blocked_keywords' => ['스팸', 'advertisement'],
@@ -288,11 +280,8 @@ class BoardSampleSeeder extends Seeder
'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']],
+2 -2
View File
@@ -5,13 +5,13 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
"g7_version": ">=7.0.0-beta.1",
"g7_version": ">=7.0.0-beta.2",
"dependencies": {
"modules": {},
"plugins": {}
+131 -2
View File
@@ -10,8 +10,14 @@ 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\BoardNotificationDataListener;
use Modules\Sirsoft\Board\Listeners\BoardCommentsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\BoardPostsCountSyncListener;
use Modules\Sirsoft\Board\Listeners\CommentReplySyncListener;
use Modules\Sirsoft\Board\Listeners\EcommerceInquiryHookListener;
use Modules\Sirsoft\Board\Listeners\PostAttachmentCountSyncListener;
use Modules\Sirsoft\Board\Listeners\PostCountSyncListener;
use Modules\Sirsoft\Board\Listeners\PostReplySyncListener;
use Modules\Sirsoft\Board\Listeners\SearchPostsListener;
use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener;
use Modules\Sirsoft\Board\Listeners\SeoBoardSettingsCacheListener;
@@ -229,6 +235,79 @@ class Module extends AbstractModule
];
}
/**
* 런타임 동적 권한 식별자 전수.
*
* BoardPermissionService 가 게시판 생성 시 아래 세 계층을 Permission 테이블에 기록한다:
* - {module} (모듈 루트)
* - {module}.{slug} (게시판 카테고리)
* - {module}.{slug}.{action} (action ∈ config board_permission_definitions)
*
* 모듈 루트는 getPermissions() 의 정적 식별자(moduleIdentifier) 와 동일하므로 생략.
* cleanup 시 이 목록에 포함된 식별자는 stale 로 판정되지 않는다.
*/
public function getDynamicPermissionIdentifiers(): array
{
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
return [];
}
$actions = array_keys((array) config('sirsoft-board.board_permission_definitions', []));
$module = $this->getIdentifier();
$ids = [];
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
$category = $module.'.'.$board->slug;
$ids[] = $category;
foreach ($actions as $action) {
$ids[] = $category.'.'.$action;
}
}
return $ids;
}
/**
* 런타임 동적 역할 식별자 전수.
*
* BoardService::createBoardRoles 가 게시판 생성 시 생성:
* - {module}.{slug}.manager
* - {module}.{slug}.step
*/
public function getDynamicRoleIdentifiers(): array
{
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
return [];
}
$module = $this->getIdentifier();
$ids = [];
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
$ids[] = $module.'.'.$board->slug.'.manager';
$ids[] = $module.'.'.$board->slug.'.step';
}
return $ids;
}
/**
* 런타임 동적 메뉴 slug 전수.
*
* BoardService 가 게시판 생성 시 `board-{slug}` 형식의 메뉴를 기록.
*/
public function getDynamicMenuSlugs(): array
{
if (! \Illuminate\Support\Facades\Schema::hasTable('boards')) {
return [];
}
$slugs = [];
foreach (\Modules\Sirsoft\Board\Models\Board::query()->select('slug')->get() as $board) {
$slugs[] = 'board-'.$board->slug;
}
return $slugs;
}
/**
* 모듈 설치 시 실행할 시더 목록 반환
*
@@ -258,13 +337,63 @@ class Module extends AbstractModule
return [
UserNotificationSettingsListener::class,
SearchPostsListener::class,
BoardNotificationListener::class,
BoardNotificationDataListener::class,
BoardNotificationChannelListener::class,
BoardActivityLogListener::class,
ActivityLogDescriptionResolver::class,
SeoBoardCacheListener::class,
SeoBoardSettingsCacheListener::class,
EcommerceInquiryHookListener::class,
PostCountSyncListener::class,
PostAttachmentCountSyncListener::class,
PostReplySyncListener::class,
CommentReplySyncListener::class,
BoardPostsCountSyncListener::class,
BoardCommentsCountSyncListener::class,
];
}
/**
* SEO 변수 메타데이터 정의
*
* 게시판 모듈이 SEO 렌더링에 제공하는 변수를 page_type별로 선언합니다.
*
* @return array page_type별 변수 정의 배열
*/
public function seoVariables(): array
{
return [
'_common' => [
'site_name' => [
'description' => '사이트명',
'source' => 'core_setting',
'key' => 'general.site_name',
],
],
'boards' => [],
'board' => [
'board_name' => [
'description' => '게시판명',
'source' => 'data',
'required' => true,
],
'board_description' => [
'description' => '게시판 설명',
'source' => 'data',
],
],
'post' => [
'board_name' => [
'description' => '게시판명',
'source' => 'data',
'required' => true,
],
'post_title' => [
'description' => '게시글 제목',
'source' => 'data',
'required' => true,
],
],
];
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.0-beta.1",
"version": "1.0.0-beta.2",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -1,581 +0,0 @@
/**
* 게시판 설정 메일 템플릿 탭/모달 레이아웃 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');
});
});
@@ -26,6 +26,7 @@ import tabReportPolicy from '../../../layouts/admin/partials/admin_board_setting
import tabSpamSecurity from '../../../layouts/admin/partials/admin_board_settings/_tab_spam_security.json';
import bulkApplyModal from '../../../layouts/admin/partials/admin_board_settings/_bulk_apply_modal.json';
import tabGeneral from '../../../layouts/admin/partials/admin_board_settings/_tab_general.json';
import tabSeo from '../../../layouts/admin/partials/admin_board_settings/_tab_seo.json';
/**
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
@@ -145,7 +146,7 @@ function findFormFields(node: any): string[] {
const names: string[] = [];
if (!node) return names;
if ((node.name === 'Input' || node.name === 'Select' || node.name === 'TagInput' || node.name === 'Toggle' || node.name === 'RadioGroup') && node.props?.name) {
if ((node.name === 'Input' || node.name === 'Select' || node.name === 'TagInput' || node.name === 'Toggle' || node.name === 'RadioGroup' || node.name === 'Textarea') && node.props?.name) {
names.push(node.props.name);
}
@@ -387,7 +388,6 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
expect(partialPaths.some((p: string) => p.includes('_tab_general.json'))).toBe(true);
expect(partialPaths.some((p: string) => p.includes('_tab_report_policy.json'))).toBe(true);
expect(partialPaths.some((p: string) => p.includes('_tab_spam_security.json'))).toBe(true);
expect(partialPaths.some((p: string) => p.includes('_tab_mail_templates.json'))).toBe(true);
// 게시판설정 8개 하위 탭 partial
expect(partialPaths.some((p: string) => p.includes('_tab_board_settings_basic.json'))).toBe(true);
@@ -666,25 +666,6 @@ describe('_tab_board_settings_notification.json - 알림 하위 탭', () => {
const fields = findFormFields(tabBoardSettingsNotification);
expect(fields).toContain('basic_defaults.notify_author');
expect(fields).toContain('basic_defaults.notify_admin_on_post');
expect(fields).toContain('basic_defaults.notify_author_channels');
expect(fields).toContain('basic_defaults.notify_admin_on_post_channels');
});
it('알림 채널 TagInput이 항상 표시된다 (if 조건 없음)', () => {
const authorChannels = findById(tabBoardSettingsNotification, 'field_notify_author_channels');
expect(authorChannels).toBeDefined();
expect(authorChannels.if).toBeUndefined();
const adminChannels = findById(tabBoardSettingsNotification, 'field_notify_admin_channels');
expect(adminChannels).toBeDefined();
expect(adminChannels.if).toBeUndefined();
});
it('채널 TagInput이 notification_channels 데이터소스를 사용한다', () => {
const authorTagInputs = findByName(tabBoardSettingsNotification, 'TagInput');
for (const tagInput of authorTagInputs) {
expect(tagInput.props.options).toContain('notification_channels');
}
});
});
@@ -1219,3 +1200,180 @@ describe('기본 설정 탭 (general) - 날짜 표시 방식', () => {
expect(body).toContain('display');
});
});
// =============================================
// N. SEO 탭 (_tab_seo.json)
// =============================================
describe('_tab_seo.json - SEO 설정 탭', () => {
it('Partial 메타데이터가 올바르다', () => {
expect(tabSeo.meta.is_partial).toBe(true);
expect(tabSeo.if).toContain("'seo'");
expect(tabSeo.id).toBe('tab_content_seo');
});
it('탭 가시성 조건이 올바르다', () => {
// activeBoardSettingsTab 또는 query.tab이 'seo'일 때만 표시
expect(tabSeo.if).toContain('activeBoardSettingsTab');
expect(tabSeo.if).toContain('query.tab');
expect(tabSeo.if).toContain("'general'");
});
it('메타 설정 카드가 존재한다', () => {
const metaCard = findById(tabSeo, 'meta_settings_card');
expect(metaCard).toBeDefined();
expect(metaCard.name).toBe('Div');
});
it('아코디언 3개가 올바르게 정의되어 있다', () => {
const accordions = [
'accordion_boards_page',
'accordion_board_page',
'accordion_post_page',
];
for (const id of accordions) {
const node = findById(tabSeo, id);
expect(node, `${id} 아코디언이 없다`).toBeDefined();
expect(node.props.className).toContain('card-accordion');
}
});
it('메타 제목/설명 입력 필드 6개가 올바른 name을 가진다', () => {
const fields = findFormFields(tabSeo);
const expectedNames = [
'seo.meta_boards_title',
'seo.meta_boards_description',
'seo.meta_board_title',
'seo.meta_board_description',
'seo.meta_post_title',
'seo.meta_post_description',
];
for (const name of expectedNames) {
expect(fields, `폼 필드 '${name}'이 없다`).toContain(name);
}
});
it('SEO 제공 페이지 체크박스 3개가 올바른 name을 가진다', () => {
const fields = findFormFields(tabSeo);
const checkboxNames = ['seo.seo_boards', 'seo.seo_board', 'seo.seo_post_detail'];
for (const name of checkboxNames) {
expect(fields, `체크박스 '${name}'이 없다`).toContain(name);
}
});
it('캐시 초기화 버튼이 존재하고 type="button"이다', () => {
const buttons = findByName(tabSeo, 'Button');
const cacheBtn = buttons.find((b: any) => b.props?.type === 'button');
expect(cacheBtn).toBeDefined();
// 로딩 중 비활성화
expect(cacheBtn.props.disabled).toContain('clearingCache');
});
it('캐시 초기화 액션이 sequence → setState → apiCall 체이닝 구조다', () => {
const buttons = findByName(tabSeo, 'Button');
const cacheBtn = buttons.find((b: any) => b.props?.type === 'button');
expect(cacheBtn.actions).toHaveLength(1);
const clickAction = cacheBtn.actions[0];
expect(clickAction.type).toBe('click');
expect(clickAction.handler).toBe('sequence');
// 1단계: clearingCache = true
const setStateAction = clickAction.actions[0];
expect(setStateAction.handler).toBe('setState');
expect(setStateAction.params.target).toBe('local');
expect(setStateAction.params.clearingCache).toBe(true);
// 2단계: 첫 번째 apiCall (board/boards 캐시 삭제)
const firstApiCall = clickAction.actions[1];
expect(firstApiCall.handler).toBe('apiCall');
expect(firstApiCall.auth_required).toBe(true);
expect(firstApiCall.target).toBe('/api/admin/seo/clear-cache');
expect(firstApiCall.params.body.layout).toBe('board/boards');
});
it('캐시 초기화 onSuccess 체이닝이 3개 레이아웃을 순차 삭제한다', () => {
const buttons = findByName(tabSeo, 'Button');
const cacheBtn = buttons.find((b: any) => b.props?.type === 'button');
const firstApiCall = cacheBtn.actions[0].actions[1];
// board/boards → board/index onSuccess
expect(firstApiCall.onSuccess.handler).toBe('apiCall');
expect(firstApiCall.onSuccess.params.body.layout).toBe('board/index');
// board/index → board/show onSuccess
expect(firstApiCall.onSuccess.onSuccess.handler).toBe('apiCall');
expect(firstApiCall.onSuccess.onSuccess.params.body.layout).toBe('board/show');
// board/show onSuccess: clearingCache=false + 성공 토스트
const finalSuccess = firstApiCall.onSuccess.onSuccess.onSuccess;
expect(Array.isArray(finalSuccess)).toBe(true);
const finalSetState = finalSuccess.find((a: any) => a.handler === 'setState');
expect(finalSetState.params.clearingCache).toBe(false);
const finalToast = finalSuccess.find((a: any) => a.handler === 'toast');
expect(finalToast.params.type).toBe('success');
});
it('캐시 초기화 onError에서 clearingCache를 false로 리셋하고 에러 토스트를 표시한다', () => {
const buttons = findByName(tabSeo, 'Button');
const cacheBtn = buttons.find((b: any) => b.props?.type === 'button');
const firstApiCall = cacheBtn.actions[0].actions[1];
// 각 단계의 onError 확인 (3곳)
for (const onError of [
firstApiCall.onError,
firstApiCall.onSuccess.onError,
firstApiCall.onSuccess.onSuccess.onError,
]) {
expect(Array.isArray(onError)).toBe(true);
const setStateErr = onError.find((a: any) => a.handler === 'setState');
expect(setStateErr.params.clearingCache).toBe(false);
const toastErr = onError.find((a: any) => a.handler === 'toast');
expect(toastErr.params.type).toBe('error');
}
});
it('모든 다국어 키가 sirsoft-board 네임스페이스를 사용한다', () => {
const allTexts: string[] = [];
function collectTexts(node: any) {
if (!node) return;
if (typeof node.text === 'string' && node.text.startsWith('$t:')) {
allTexts.push(node.text);
}
if (Array.isArray(node.children)) {
node.children.forEach(collectTexts);
}
}
collectTexts(tabSeo);
expect(allTexts.length).toBeGreaterThan(0);
for (const text of allTexts) {
expect(text, `잘못된 다국어 키: ${text}`).toMatch(/^\$t:sirsoft-board\./);
}
});
it('메인 레이아웃 TabNavigation에 seo 탭이 포함되어 있다', () => {
const tabNavList = findByName(mainLayout, 'TabNavigation');
expect(tabNavList.length).toBeGreaterThan(0);
const tabs = tabNavList[0]?.props?.tabs as any[];
const seoTab = tabs.find((t: any) => t.id === 'seo');
expect(seoTab).toBeDefined();
expect(seoTab.label).toContain('$t:sirsoft-board.');
});
it('메인 레이아웃 Partial 목록에 _tab_seo.json이 포함되어 있다', () => {
const content = mainLayout.slots.content[0];
const mainContent = findById(content, 'main_content');
const partialPaths = mainContent.children
.filter((c: any) => c.partial)
.map((p: any) => p.partial as string);
expect(partialPaths.some((p) => p.includes('_tab_seo.json'))).toBe(true);
});
it('저장 body에 seo 탭 분기 로직이 있다', () => {
const content = mainLayout.slots.content[0];
const saveBtn = findById(content, 'save_button');
const apiCallAction = saveBtn.actions[0].actions[1];
const body = apiCallAction.params.body as string;
expect(body).toContain("'seo'");
expect(body).toContain('seo:');
});
});
@@ -3,7 +3,33 @@
"$partial": "partial/en/common.json"
},
"admin": {
"$partial": "partial/en/admin.json"
"board": {
"$partial": "partial/en/admin/board.json"
},
"modals": {
"$partial": "partial/en/admin/modals.json"
},
"form": {
"$partial": "partial/en/admin/form.json"
},
"reports": {
"$partial": "partial/en/admin/reports.json"
},
"posts": {
"$partial": "partial/en/admin/posts.json"
},
"settings": {
"$partial": "partial/en/admin/settings.json"
},
"board_types": {
"$partial": "partial/en/admin/board_types.json"
},
"users": {
"$partial": "partial/en/admin/users.json"
},
"ecommerce_settings": {
"$partial": "partial/en/admin/ecommerce_settings.json"
}
},
"board": {
"$partial": "partial/en/board.json"
@@ -3,7 +3,33 @@
"$partial": "partial/ko/common.json"
},
"admin": {
"$partial": "partial/ko/admin.json"
"board": {
"$partial": "partial/ko/admin/board.json"
},
"modals": {
"$partial": "partial/ko/admin/modals.json"
},
"form": {
"$partial": "partial/ko/admin/form.json"
},
"reports": {
"$partial": "partial/ko/admin/reports.json"
},
"posts": {
"$partial": "partial/ko/admin/posts.json"
},
"settings": {
"$partial": "partial/ko/admin/settings.json"
},
"board_types": {
"$partial": "partial/ko/admin/board_types.json"
},
"users": {
"$partial": "partial/ko/admin/users.json"
},
"ecommerce_settings": {
"$partial": "partial/ko/admin/ecommerce_settings.json"
}
},
"board": {
"$partial": "partial/ko/board.json"
@@ -747,7 +747,7 @@
},
"search_fields": {
"all": "All",
"title": "Title",
"title_content": "Title+Content",
"author": "Author (Name, Email)"
},
"filters": {
@@ -839,7 +839,7 @@
"basic_defaults": "Board Settings",
"report_policy": "Report Policy",
"spam_security": "Security",
"mail_templates": "Mail Templates"
"notification_definitions": "Notifications"
},
"board_settings_tabs": {
"basic": "Basic",
@@ -1104,17 +1104,15 @@
"max_file_count": "Max File Count",
"allowed_extensions": "Allowed Extensions",
"notify_admin_on_post": "Admin Notification",
"notify_admin_on_post_channels": "Admin Notification Channels",
"notify_author": "Author Notification",
"notify_author_channels": "Author Notification Channels",
"new_display_hours": "New Post Display Hours",
"default_board_permissions": "Default Permissions",
"manager": "Manage Board"
}
},
"mail_templates": {
"title": "Mail Template Management",
"description": "Manage email templates for board notifications. You can customize the subject and body for each notification type.",
"notification_definitions": {
"title": "Notification Settings",
"description": "Manage notification templates for board. You can customize the subject and body for each notification type.",
"active": "Active",
"inactive": "Inactive",
"default_badge": "Default",
@@ -1122,10 +1120,10 @@
"btn_deactivate": "Deactivate",
"btn_edit": "Edit",
"btn_reset": "Reset to Default",
"toggle_success": "Mail template status has been changed.",
"reset_success": "Mail template has been restored to default.",
"save_success": "Mail template saved successfully.",
"save_error": "Failed to save mail template.",
"toggle_success": "Notification active status has been changed.",
"reset_success": "Notification has been reset to default.",
"save_success": "Notification settings saved successfully.",
"save_error": "Failed to save notification settings.",
"types": {
"new_comment": "New Comment",
"reply_comment": "Reply Comment",
@@ -1143,20 +1141,21 @@
"search_type_body": "Body",
"total_count": "Total {{count}} items",
"per_page_label": "Per page",
"empty_message": "No mail templates registered.",
"empty_message": "No notification settings found.",
"empty_no_results": "No search results found.",
"edit_modal": {
"title": "Edit Mail Template",
"title": "Edit Notification Template",
"type_label": "Type:",
"subject": "Subject",
"body": "Body",
"channels_label": "Active Channels",
"variables_title": "Available Variables",
"btn_preview": "Preview",
"validation_error": "Please check the input fields.",
"is_active_label": "Active"
},
"preview_modal": {
"title": "Mail Template Preview",
"title": "Notification Preview",
"subject_label": "Subject",
"body_label": "Body"
}
@@ -0,0 +1,51 @@
{
"index": {
"title": "Board Management",
"description": "Manage board list",
"search_placeholder": "Search boards...",
"search": "Search",
"add_board": "Add Board",
"post_count": "Posts:",
"created_at": "Created:",
"categories_label": "Categories:",
"empty_title": "No boards found",
"empty_description": "No boards have been registered.",
"filter_all_types": "All Types",
"filter_type_basic": "Basic List",
"filter_type_gallery": "Gallery Grid",
"filter_type_card": "Card Grid",
"status": "Status",
"active": "Active",
"inactive": "Inactive"
},
"create": {
"title": "Create Board"
},
"edit": {
"title": "Edit Board"
},
"columns": {
"type": "Type",
"name": "Board Name",
"slug": "Slug",
"posts_count": "Posts",
"created_at": "Created At"
},
"actions": {
"edit": "Edit",
"manage_posts": "View Posts",
"copy": "Copy",
"delete": "Delete"
},
"messages": {
"create_success": "Board created successfully",
"update_success": "Board updated successfully",
"delete_success": "Board deleted successfully",
"deleted": "Board has been deleted",
"copy_success": "Board copied successfully",
"save_failed": "Failed to save",
"table_not_found": "Cannot delete board because the board table does not exist",
"activated": "Board has been activated",
"deactivated": "Board has been deactivated"
}
}
@@ -0,0 +1,19 @@
{
"manage": "Manage",
"modal_title": "Board Type Management",
"slug": "Slug",
"name": "Name",
"actions": "Actions",
"empty": "No types registered",
"delete_confirm": "Are you sure you want to delete this type?",
"delete_success": "Type has been deleted",
"edit_title": "Edit Type",
"add_title": "Add Type",
"add": "Add Type",
"adding": "Adding...",
"name_placeholder": "e.g., Basic List",
"slug_hint": "Only lowercase letters, numbers, and hyphens (-) allowed. Must start with a lowercase letter. (e.g., basic, gallery, photo-card)",
"update_success": "Type has been updated",
"create_success": "Type has been created",
"usage_guide": "The slug is an identifier that distinguishes the board display type. You must add and configure a dedicated layout for the slug in the template for it to take effect."
}
@@ -0,0 +1,15 @@
{
"inquiry": {
"description": "You can designate the board to use as the product inquiry board.",
"board_label": "Inquiry Board",
"board_placeholder": "Select a board",
"hint_secret": "ℹ A board with the secret post feature enabled is recommended.",
"go_to_board_edit": "Go to Board Settings",
"go_to_board_edit_desc": "Change detailed board settings such as secret post options, read/write permissions, and categories.",
"hint_category": "ℹ If you set up categories on the board, users can select a category when submitting an inquiry.",
"go_to_posts": "Manage Inquiries",
"go_to_posts_desc": "View and respond to product inquiries submitted by customers. Add this board to the admin sidebar menu for quicker access.",
"add_to_admin_menu": "Add to Admin Menu",
"add_to_admin_menu_desc": "Adds a posts management item for this board to the sidebar admin menu."
}
}
@@ -0,0 +1,222 @@
{
"title_create": "Create Board",
"title_edit": "Edit Board",
"description": "Manage board settings",
"read_only_message": "You have read-only access. Contact an administrator if you need to make changes.",
"validation_error": "Please check the input information.",
"add_category": "Add Category",
"tabs": {
"basic": "Basic Settings",
"permissions": "Permission Settings",
"list": "List Settings",
"post": "Post Settings",
"notification": "Notification Settings"
},
"fields": {
"name": "Board Name",
"slug": "Slug",
"description": "Board Description",
"is_active": "Active",
"type": "Board Type",
"categories": "Categories",
"show_view_count": "Show View Count",
"use_comment": "Enable Comments",
"use_reply": "Enable Reply Posts",
"use_report": "Enable Reports",
"secret_mode": "Secret Mode",
"order_by": {
"label": "Sort By",
"options": {
"created_at": "Latest",
"view_count": "Views",
"title": "Title",
"author": "Author"
}
},
"order_direction": {
"label": "Sort Direction",
"options": {
"desc": "Descending",
"asc": "Ascending"
}
},
"per_page": {
"label": "Posts Per Page (PC)",
"hint": "Enter a value between {{min}} and {{max}}"
},
"per_page_mobile": {
"label": "Posts Per Page (Mobile)",
"hint": "Enter a value between {{min}} and {{max}}"
},
"min_title_length": {
"label": "Minimum Title Length",
"hint": "Enter a value between {{min}} and {{max}}"
},
"max_title_length": {
"label": "Maximum Title Length",
"hint": "Enter a value between {{min}} and {{max}}"
},
"min_content_length": {
"label": "Minimum Content Length",
"hint": "Enter a value between {{min}} and {{max}}"
},
"max_content_length": {
"label": "Maximum Content Length",
"hint": "Enter a value between {{min}} and {{max}}"
},
"min_comment_length": {
"label": "Minimum Comment Length",
"hint": "Enter a value between {{min}} and {{max}}"
},
"max_comment_length": {
"label": "Maximum Comment Length",
"hint": "Enter a value between {{min}} and {{max}}"
},
"blocked_keywords": {
"label": "Blocked Keywords",
"placeholder": "Comma-separated (e.g., spam, abuse, ads)",
"description": "Inappropriate words or keywords to exclude"
},
"use_file_upload": {
"label": "File Upload Settings",
"description": "Allow file attachments to posts."
},
"max_file_count": {
"label": "Maximum File Count",
"hint": "Up to {{max}} files can be uploaded"
},
"max_file_size": {
"label": "Maximum File Size (MB)",
"placeholder": "Enter in MB",
"hint": "Up to {{maxMB}}MB can be uploaded"
},
"allowed_extensions": {
"label": "Allowed Extensions",
"placeholder": "Comma-separated (e.g., jpg, png, pdf)",
"description": "All extensions allowed if empty"
},
"comment_order": {
"label": "Comment Sort Order",
"options": {
"asc": "Ascending (Oldest First)",
"desc": "Descending (Newest First)"
}
},
"new_display_hours": {
"label": "New Post Display Period",
"hint": "Hours to display [New] badge after post creation"
},
"max_reply_depth": {
"label": "Max Reply Depth",
"hint": "Enter a value between {{min}} and {{max}}"
},
"max_comment_depth": {
"label": "Max Comment Depth",
"hint": "Enter a value between {{min}} and {{max}}. Setting to 0 disables the nested comment feature."
},
"notify_author": {
"label": "Author Email Notification",
"description": "Send email to author for comments, replies, and admin actions"
},
"notify_admin_on_post": {
"label": "Admin Email Notification",
"description": "Send email to admin when a post is created"
},
"notification_channels": {
"label": "Notification Channels",
"description": "Select channels to receive board notifications",
"channels_label": "Channels",
"channel_mail": "Email",
"channel_database": "Site Notification"
}
},
"placeholders": {
"name": "e.g., Notice Board",
"slug": "e.g., notice (letters, numbers, hyphens only)",
"description": "Briefly describe the purpose of this board",
"select_type": "Select board type"
},
"descriptions": {
"slug": "Used for URL and table name (letters, numbers, hyphens only)",
"description": "Description displayed to users in the board list",
"is_active": "Inactive boards will not be displayed on user pages",
"categories": "Manage post categories",
"categories_notice": "Deleting a category will not affect its posts. They can be found using the 'Unclassified' filter.",
"show_view_count": "Display view count in list and detail pages",
"use_comment": "When enabled, you can configure detailed comment settings (length, depth, order).",
"use_reply": "When enabled, you can set the maximum reply depth.",
"use_report": "Enable report feature for inappropriate posts/comments",
"type": "Board types can be managed in Settings > Basic Settings."
},
"options": {
"secret_mode": {
"disabled": "Disabled",
"enabled": "Optional",
"always": "Always Secret"
}
},
"permissions": {
"managers_title": "Board Managers",
"managers_description": "Managers have full permissions for this board.",
"managers_select_button": "Select Manager",
"manager_email_placeholder": "Enter manager email",
"search_manager": "Search",
"confirm_add": "Add",
"select": "Select",
"change": "Change",
"users_found": "Found {{count}} user(s)",
"no_users_found_search": "No users found",
"user_search_failed": "Failed to search users",
"users_found_with_limit": "Found {{count}} user(s) (max 20 displayed)",
"user_added": "{{name}} has been added",
"no_search_results": "No search results",
"selected_managers": "Selected Managers",
"select_managers": "Select Managers",
"admin_permissions_title": "Admin Permissions",
"admin_permissions_description": "Permissions for accessing admin pages. Assigned to board managers and system administrators.",
"user_permissions_title": "User Permissions",
"user_permissions_description": "User access permissions for the board. Detailed permissions are controlled through roles.",
"settings_title": "Role-based Access Control",
"settings_description": "Select roles to assign to each permission. If no roles are selected, all users are allowed.",
"search_users": "Search by name or email",
"no_users_found": "No users found",
"select_roles": "Select roles (required)",
"mode_all": "All",
"mode_roles": "Roles",
"header_feature": "Feature",
"header_access": "Access",
"header_all_access": "All Access",
"header_role_selection": "Role Selection",
"all_access": "All Access",
"all_access_enabled": "All users can access.",
"role_restricted": "Role Restricted",
"select_roles_hint": "Select roles to allow access. (required)",
"admin_count": "{{count}} permissions",
"user_count": "{{count}} permissions",
"error_title": "Permission Configuration Error",
"error_description": "Please select at least one role for each permission.",
"error_roles_required": "Please select roles.",
"select_manager": "Select Manager",
"search_placeholder": "Search by name or email (Shows up to 20 results, please be specific)",
"steps_title": "Board Steps",
"steps_description": "Steps have all permissions except board management permission.",
"auto_role_notice": "Board manager and step roles are included as default values for each permission upon creation and can be modified below.",
"create_mode_permissions_notice": "Role-based access control can be configured in the edit screen after creating the board. Default permissions are automatically applied during creation."
},
"modals": {
"category_remove": {
"title": "Remove Category",
"warning": "This action cannot be undone",
"message": "Are you sure you want to remove '{{category}}' category?"
}
},
"menu": {
"manual_guide": "Enter the path below in Menu Management to register.",
"manual_admin_label": "Admin Menu Path",
"manual_user_label": "User Menu Path",
"example": "e.g.",
"add_to_admin_menu": "Add to Admin Menu",
"menu_added_success": "Successfully added to admin menu.",
"menu_add_failed": "Failed to add menu."
}
}
@@ -0,0 +1,26 @@
{
"copy": {
"title": "Copy Board",
"message": "Would you like to copy '{{name}}' board?",
"info": "※ Only board settings will be copied, posts will not be copied.",
"new_slug_label": "New Slug",
"new_slug_placeholder": "e.g., notice-copy",
"confirm": "Copy"
},
"delete": {
"title": "Delete Board",
"confirm": "Are you sure you want to delete '{{name}}' board?",
"with_posts_info": "This board has {{posts_count}} post(s) and comments.",
"no_posts_info": "This board has no posts.",
"force_delete_label_with_posts": "Permanent Delete (Delete all posts, comments, and attachments)",
"force_delete_label_no_posts": "Permanent Delete (Delete database tables)",
"force_delete_warning_with_posts": "Permanent deletion will remove all data irreversibly. Please backup your data before deletion.",
"force_delete_warning_no_posts": "This action cannot be undone. The board and all related data will be completely removed.",
"backup_notice": "It is recommended to backup your database before deletion to prevent data loss."
},
"cancel_confirm": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Are you sure you want to leave this page?",
"confirm": "Leave Page"
}
}
@@ -0,0 +1,284 @@
{
"page_title": "Posts Management",
"page_description": "Manage board posts",
"page_description_with_board": "'s posts management",
"add_post": "Add Post",
"search_placeholder": "Search posts...",
"advanced_search": "Advanced Search",
"search": "Search",
"reset": "Reset",
"create": {
"title": "Create Post"
},
"reply": {
"title": "Write Reply Post"
},
"list": {
"reply": "Reply",
"notice": "Notice"
},
"detail": {
"notice": "Notice",
"title": "Post Detail",
"description": "View detailed post information",
"reply_button": "Reply",
"view_detail_button": "View Details",
"report": "Report",
"reported": "Reported",
"view_count": "Views {{count}}",
"attachments": "Attachments ({{count}})",
"prev": "Previous",
"next": "Next",
"comments_title": "Comments ({{count}})",
"no_comments": "No comments yet. Be the first to comment!",
"comment_placeholder": "Enter your comment...",
"comment_submit": "Submit Comment",
"comment_submitting": "Submitting",
"comment_submit_success": "Comment has been posted.",
"comment_edit_success": "Comment has been updated.",
"comment_reply": "Submit Reply",
"reply": "Reply",
"reply_submitting": "Submitting",
"reply_placeholder": "Enter your reply...",
"replying_to": "Replying to {{name}}...",
"reply_submit_success": "Reply has been submitted.",
"guest_password_verify": "Password Verification",
"guest_password_verify_message": "Please enter the password to edit your post.",
"password_placeholder": "Enter password",
"password_incorrect": "Incorrect password.",
"comment_added": "Comment has been posted.",
"show_replies": "Show {{count}} reply posts",
"hide_replies": "Hide reply posts",
"reply_blocked_blinded_post": "Cannot write a reply post to a blinded post.",
"reply_blocked_deleted_post": "Cannot write a reply post to a deleted post.",
"user_info": "User Info",
"admin_badge": "Admin",
"cancel": "Cancel",
"edit": "Edit",
"replies_title": "Replies ({{count}})",
"reply_secret_content": "This is a private post",
"reply_view_detail": "View Details",
"load_more_comments": "Load {{count}} more comments",
"status_blinded": "Blinded",
"status_deleted": "Deleted",
"status_published": "Published",
"action_logs_title": "Management History",
"action_logs_empty": "No management history available.",
"action_log_blind": "Blinded",
"action_log_delete": "Deleted",
"action_log_restore": "Restored",
"action_log_by": "By:",
"action_log_reason": "Reason:",
"action_log_at": "Date:",
"blinded_notice": "This post has been blinded",
"blinded_description": "This post has been hidden according to our policy",
"blinded_comment_notice": "This comment has been blinded",
"blinded_comment_description": "This comment has been hidden according to our policy",
"deleted_comment_notice": "This comment has been deleted",
"blind_reason_label": "Reason:",
"blinded_at": "Blinded at: {{date}}",
"view_original_content": "View Original Content",
"original_content_label": "Original Content",
"hide_original_content": "Hide Content",
"restore": "Restore",
"restore_confirm_title": "Restore Post",
"restore_confirm_message": "Are you sure you want to restore this post?",
"restore_success": "Post has been restored.",
"parent_post_title": "Original Post",
"parent_title_label": "Title",
"parent_author_label": "Author",
"parent_date_label": "Date",
"view_parent_post": "View Original Post",
"deleted_at": "Deleted at: {{date}}",
"delete_modal": {
"title_post": "Delete Post",
"title_reply": "Delete Reply",
"title_comment": "Delete Comment",
"confirm_post": "Are you sure you want to delete this post?",
"confirm_reply": "Are you sure you want to delete this reply?",
"confirm_comment": "Are you sure you want to delete this comment?",
"warning_comment": "Deleted comments cannot be recovered.",
"warning_reply": "Deleted replies cannot be recovered.",
"success_post": "Post has been deleted.",
"success_reply": "Reply has been deleted.",
"success_comment": "Comment has been deleted.",
"button_delete": "Delete",
"error": "Failed to delete."
},
"blind_modal": {
"title": "Blind Post",
"title_comment": "Blind Comment",
"reason_label": "Reason for Blinding",
"reason_placeholder": "Enter the reason for blinding (minimum 10 characters)",
"submit": "Blind",
"success": "Post has been blinded.",
"success_comment": "Comment has been blinded."
},
"restore_modal": {
"title": "Restore",
"title_comment": "Restore Comment",
"reason_label": "Reason for Restoration (optional)",
"reason_placeholder": "Enter the reason for restoration",
"submit": "Restore",
"success": "Post has been restored.",
"success_comment": "Comment has been restored."
},
"report_modal": {
"title": "Report Post",
"title_comment": "Report Comment",
"warning_notice": "Reports cannot be cancelled. Please check carefully before reporting. Do you want to proceed?",
"reason_type_label": "Report Type",
"reason_type_placeholder": "Select report type",
"reason_type_required": "Please select a report type.",
"reason_detail_label": "Detailed Reason",
"reason_label": "Report Reason",
"reason_placeholder": "Enter the specific reason for reporting",
"reason_required": "Please enter a detailed reason.",
"submit": "Submit Report",
"success": "Report has been submitted.",
"success_comment": "Comment report has been submitted.",
"warning": "⚠️ Reports cannot be cancelled. Please decide carefully.",
"error": "Failed to submit report.",
"report_target": "Report Target: ",
"comment_by_author": "Comment by {{author}}"
}
},
"form": {
"description": "Create or edit a post",
"read_only_message": "You have read-only access. Contact an administrator if you need to make changes.",
"author_info": "Author",
"author": "Author Name",
"email": "Email",
"created_at": "Created At",
"is_notice": "Notice",
"notice_description": "Pin this post to the top of the board",
"is_secret": "Secret",
"secret_post_always": "This board always writes as secret posts",
"secret_description": "Only viewable by author and administrators",
"category": "Category",
"select_category": "Select Category",
"title": "Title",
"title_placeholder": "Enter post title",
"content": "Content",
"content_placeholder": "Enter post content",
"show_full_content": "Show Full Content",
"hide_content": "Hide Content",
"html_mode": "HTML Mode",
"show_preview": "Preview",
"hide_preview": "Hide Preview",
"preview_title": "HTML Preview",
"edit_mode": "Edit",
"preview_mode": "Preview",
"preview_html": "HTML Preview",
"html_preview_warning": "HTML Rendering Warning",
"no_content_preview": "Preview will appear when you enter content",
"attachments": "Attachments",
"attached_files": "Attached Files",
"confirm_remove_attachment_title": "Delete Attachment",
"confirm_remove_attachment_message": "The file will be deleted immediately. This action cannot be undone regardless of saving the post.",
"attachments_limit": "Max 5 files, 10MB each (jpg, jpeg, png, gif, pdf, zip)",
"drop_files_or_click": "Click to upload or drag and drop",
"max_10mb": "Max 10MB",
"file_upload_component_needed": "FileUpload component needed",
"uploading": "Uploading...",
"reply_to": "Reply to the following post",
"view_original": "View Original Post",
"post_options": "Post Options",
"post_content": "Post Content",
"parent_post_info": "Original Post Information",
"edit_title": "Edit Post",
"create_title": "Create Post"
},
"messages": {
"create_success": "Post has been successfully created",
"update_success": "Post has been successfully updated",
"save_failed": "Failed to save"
},
"columns": {
"number": "No.",
"title": "Title",
"category": "Category",
"author": "Author",
"view_count": "Views",
"created_at": "Created At"
},
"search_fields": {
"all": "All",
"title_content": "Title+Content",
"author": "Author (Name, Email)"
},
"filters": {
"category_label": "Category",
"category_all": "All Categories",
"category_unclassified": "Unclassified",
"date_from": "Date From",
"date_to": "Date To",
"status_label": "Post Status",
"status_all": "All",
"status_published": "Published",
"status_blinded": "Blinded",
"status_deleted": "Deleted",
"status_notice": "Notice",
"status_secret": "Secret"
},
"status": {
"published": "Published",
"blinded": "Blinded",
"notice": "Notice",
"secret": "Secret"
},
"actions": {
"view": "View",
"edit": "Edit",
"blind": "Blind",
"restore": "Restore",
"delete": "Delete",
"create": "Create Post"
},
"author_actions": {
"view_user": "View User Info"
},
"modals": {
"delete_title": "Delete Post",
"delete_confirm": "Are you sure you want to delete this post? This action cannot be undone.",
"delete_success": "Post has been successfully deleted.",
"blind_title": "Blind Post",
"blind_comment_title": "Blind Comment",
"blind_confirm": "Are you sure you want to blind this post? Blinded posts will not be visible to regular users.",
"blind_comment_confirm": "Are you sure you want to blind this comment? Blinded comments will not be visible to regular users.",
"blind_reason_label": "Reason for Blinding",
"blind_reason_placeholder": "Enter the reason for blinding...",
"blind_success": "Post has been blinded successfully.",
"blind_comment_success": "Comment has been blinded successfully.",
"restore_title": "Restore Post",
"restore_comment_title": "Restore Comment",
"restore_confirm": "Are you sure you want to restore this post? Restored posts will be visible to regular users again.",
"restore_comment_confirm": "Are you sure you want to restore this comment? Restored comments will be visible to regular users again.",
"restore_reason_label": "Reason for Restoring",
"restore_reason_placeholder": "Enter the reason for restoring...",
"restore_success": "Post has been restored successfully.",
"restore_comment_success": "Comment has been restored successfully.",
"blind_error": "Failed to blind.",
"restore_error": "Failed to restore."
},
"guest": "Guest",
"notice": "Notice",
"secret": "Secret",
"unknown_author": "Unknown",
"pagination_info": "Showing {{from}}-{{to}} of {{total}} total",
"badge": {
"notice": "[Notice]"
},
"sort": {
"latest": "Latest",
"oldest": "Oldest",
"view_count_high": "Most Views",
"view_count_low": "Least Views"
},
"per_page": {
"15": "15 per page",
"30": "30 per page",
"50": "50 per page",
"100": "100 per page"
}
}
@@ -0,0 +1,243 @@
{
"page_title": "Board Reports",
"page_description": "View and manage reported posts and comments.",
"guest_badge": "Guest",
"search_placeholder": "Search reports...",
"search": "Search",
"reset": "Reset",
"advanced_search": "Advanced Search",
"selected_count": "{{count}} selected",
"columns": {
"number": "No.",
"type": "Type",
"board_name": "Board",
"post_title": "Post Title",
"content": "Content",
"author_name": "Author",
"reporter_name": "Reporter",
"reported_at": "Reported At",
"last_reported_at": "Last: {{date}}",
"last_reported_at_label": "Latest",
"target_status": "Target Status",
"status": "Status",
"current_status": "Current Status"
},
"status_labels": {
"published": "Published",
"blind": "Blinded",
"deleted": "Deleted"
},
"statuses": {
"pending": "Received",
"review": "Review",
"rejected": "Rejected",
"suspended": "Suspended"
},
"filters": {
"status_label": "Status",
"status_all": "All",
"status_pending": "Received",
"status_review": "Review",
"status_rejected": "Rejected",
"status_suspended": "Suspended",
"status_deleted": "Permanently Deleted",
"type_label": "Type",
"type_all": "All",
"type_post": "Post",
"type_comment": "Comment",
"date_from_label": "From",
"date_to_label": "To",
"target_status_label": "Target Status",
"target_status_published": "Published",
"target_status_blinded": "Blinded",
"target_status_deleted": "Deleted",
"reported_at_label": "Report Date",
"date_quick_today": "Today",
"date_quick_3days": "3 Days",
"date_quick_week": "1 Week",
"date_quick_month": "1 Month",
"date_quick_3months": "3 Months",
"date_quick_all": "All"
},
"search_fields": {
"all": "All",
"post_title": "Post Title",
"board_name": "Board",
"author_name": "Author Name",
"reporter_name": "Reporter Name"
},
"actions": {
"view": "View Details",
"delete": "Delete",
"review": "Review",
"reject": "Reject",
"suspend": "Suspend",
"go_to_board": "Go to Board",
"go_to_post": "Go to Reported Post"
},
"author_actions": {
"view_info": "Author Info",
"search": "Search Author",
"view_detail": "User Info"
},
"reporter_actions": {
"view_info": "Reporter Info",
"search": "Search Reporter",
"view_detail": "User Info"
},
"modals": {
"status_change_pending_title": "Change to Received",
"status_change_pending_confirm": "Change {{count}} selected item(s) to received status?",
"status_change_pending_confirm_single": "Change this report to received status?",
"status_change_review_title": "Change to Review",
"status_change_review_confirm": "Change {{count}} selected item(s) to review status?",
"status_change_review_confirm_single": "Change this report to review status?",
"status_change_rejected_title": "Change to Reject",
"status_change_rejected_confirm": "Change {{count}} selected item(s) to rejected status?",
"status_change_rejected_confirm_single": "Change this report to rejected status?",
"status_change_suspended_title": "Change to Suspend",
"status_change_suspended_confirm": "Suspend {{count}} selected item(s)?",
"status_change_suspended_confirm_single": "Suspend this report?",
"status_change_deleted_title": "Change to Permanent Deletion",
"status_change_deleted_confirm": "Are you sure you want to permanently delete {{count}} selected item(s)? Deleted posts cannot be recovered.",
"status_change_deleted_confirm_single": "Are you sure you want to permanently delete this report? Deleted content cannot be recovered.",
"reason_label": "Reason (Optional)",
"reason_placeholder": "Enter reason...",
"processing": "Processing...",
"loading_status": "Loading status information...",
"selected_status_title": "Selected Report Status (Changeable)",
"count_unit": "{{count}}items",
"total_count_message": "Total {{count}} items",
"deleted_warning": "{{count}} permanently deleted item(s) cannot be changed.",
"same_status_warning": "{{count}} item(s) already in {{status}} status cannot be changed.",
"blinded_will_be_public": "{{count}} blinded content(s) will be made public.",
"changeable_warning_with_deleted": "Would you like to change {{count}} changeable report(s) to {{status}} status?",
"changeable_warning_no_deleted": "Would you like to change {{count}} selected report(s) to {{status}} status?",
"all_deleted_warning_title": "Cannot Change Status",
"all_deleted_warning_message": "Permanently deleted reports cannot have their status changed.",
"all_unchangeable_warning_title": "Cannot Change Status",
"all_unchangeable_warning_message": "All selected reports are unchangeable. (Permanently deleted or already in the same status)",
"permanent_delete_warning": "Once permanently deleted, posts cannot be recovered.",
"status_change_success": "Status changed successfully.",
"status_change_success_with_count": "Changed {{count}} report(s) to {{status}}.",
"status_label_pending": "Pending",
"status_label_review": "Under Review",
"status_label_rejected": "Rejected",
"status_label_suspended": "Suspended",
"status_label_deleted": "Deleted",
"delete_title": "Delete Report",
"delete_confirm": "Are you sure you want to delete this report? This action cannot be undone and all related data will be permanently deleted.",
"delete_success": "Report deleted successfully."
},
"pagination_info": "Showing {{from}}-{{to}} of {{total}} reports",
"bulk_action_select": "Select bulk action",
"status_change_label": "Change Status",
"detail": {
"page_title": "Report Details",
"page_description": "View and manage detailed information about reported posts or comments.",
"report_count_with_unit": "{count} Report(s)",
"alert_title_pending": "Action Required",
"alert_message_pending": "This report has not been processed yet. Please review and take appropriate action.",
"alert_title_review": "Under Review",
"alert_message_review": "This report is currently under review. Please complete additional review and make final decision.",
"alert_title_resolved": "Resolved",
"alert_message_resolved": "This report has been successfully resolved.",
"alert_title_rejected": "Rejected",
"alert_message_rejected": "This report has been rejected.",
"alert_title_suspended": "Content Suspended",
"alert_message_suspended": "The reported content has been suspended.",
"alert_title_deleted": "Permanently Deleted",
"alert_message_deleted": "The reported content has been permanently deleted and cannot be recovered.",
"actions": {
"start_review": "Start Review",
"back_to_pending": "Cancel Review",
"re_review": "Re-review",
"review": "Review Process",
"reject": "Reject Report",
"suspend": "Suspend Content",
"delete": "Permanently Delete"
},
"post_info_title": "Post/Comment Information",
"post_info_title_post": "Post Information",
"post_info_title_comment": "Comment Information",
"report_info_title": "Report Information",
"board_label": "Board",
"board_deleted": "Deleted",
"type_label": "Type",
"title_label": "Title",
"post_title_label": "Original Post Title",
"author_label": "Author",
"content_author_label": "Author Name",
"created_at_label": "Created At",
"content_label": "Content",
"reported_content_label": "Reported Content",
"reports_title": "Report History",
"reporter_label": "Reporter",
"reported_at_label": "Reported At",
"reason_type_label": "Reason Type",
"reason_detail_label": "Detailed Reason",
"target_status_label": "Target Status:",
"report_summary_label": "Report Types:",
"first_reported_at_label": "Report Date:",
"comment_author_label": "Comment Author",
"post_created_at_label": "Post Date",
"comment_created_at_label": "Comment Date",
"name_label": "Name",
"username_label": "Username",
"view_user_details": "View User Details",
"user_info": "User Info",
"histories_title": "Processing History",
"history_timeline_title": "Processing Timeline",
"history_timeline": {
"reported": "Report received",
"re_reported": "Re-accepted",
"review": "Under review",
"rejected": "Rejected",
"suspended": "Suspended",
"deleted": "Deleted",
"restored": "Restored"
},
"no_histories": "No processing history available.",
"cancelled_badge": "Cancelled ({{cancelled_at}})",
"deleted_user": "Deleted User",
"table": {
"action": "Action",
"processor": "Processor",
"reason": "Reason",
"date": "Date",
"reporter": "Reporter",
"reported_at": "Reported At",
"cancelled_at": "Cancelled At",
"reason_type": "Reason Type",
"reason_detail": "Reason Detail"
},
"modals": {
"review_title": "Review Process",
"review_description": "Please review the report and provide a reason for processing.",
"reject_title": "Reject Report",
"reject_description": "Please provide a reason for rejecting this report.",
"suspend_title": "Suspend Content",
"suspend_description": "Please provide a reason for suspending this post/comment.",
"reason_label": "Reason",
"reason_placeholder": "Enter your reason..."
},
"messages": {
"review_success": "Review process has been completed successfully.",
"reject_success": "Report has been rejected successfully.",
"suspend_success": "Content has been suspended successfully."
}
},
"bulk_validation": {
"modal_title": "Bulk Processing Result",
"success_count": "Successful",
"failure_count": "Failed",
"failed_items_title": "Failed Items",
"column_id": "ID",
"column_target": "Target",
"column_reporter": "Reporter",
"column_status": "Current Status",
"column_reason": "Failure Reason",
"no_failed_items": "No failed items.",
"process_available_only": "Process Available Items Only"
}
}
@@ -0,0 +1,393 @@
{
"title": "Board Settings",
"description": "Configure default settings for new boards and manage global board policies.",
"read_only_message": "You have read-only access. Contact an administrator if you need to make changes.",
"page_info": "Manage default settings applied when creating new boards. Check the checkbox next to each item to bulk-apply it to existing boards.",
"validation_error": "Please check the input values.",
"save_success": "Settings saved successfully.",
"save_error": "Failed to save settings.",
"unsaved_changes": "You have unsaved changes.",
"tabs": {
"general": "General",
"basic_defaults": "Board Settings",
"report_policy": "Report Policy",
"spam_security": "Security",
"seo": "SEO Settings",
"mail_templates": "Mail Templates",
"notification_definitions": "Notification Settings"
},
"board_settings_tabs": {
"basic": "Basic",
"permissions": "Permissions",
"list": "List",
"post": "Post",
"reply": "Reply",
"comment": "Comment",
"attachment": "Attachment",
"notification": "Notification",
"bulk_apply": "Bulk Apply"
},
"sections": {
"general": "General",
"general_description": "Settings applied immediately across all boards on the user-facing pages",
"basic": "Basic Settings",
"basic_description": "Default settings applied when creating a new board. These can be changed individually per board, and the 'Bulk Apply' feature allows you to apply them to existing boards as well.",
"permissions": "Permission Settings",
"permissions_description": "Default permissions applied when creating a new board",
"list": "List Settings",
"list_description": "Settings related to the board list page",
"post": "Post Settings",
"post_description": "Settings related to post writing",
"reply": "Reply Post Settings",
"reply_description": "Settings related to reply posts",
"comment": "Comment Settings",
"comment_description": "Settings related to comments",
"attachment": "Attachment Settings",
"attachment_description": "Settings related to file uploads",
"notification": "Notification Settings",
"notification_description": "Settings related to notifications",
"report": "Report Settings",
"report_description": "Settings related to the report feature",
"report_policy": "Report Policy Settings",
"report_policy_description": "Configure automatic report handling and restriction policies",
"auto_hide": "Auto Hide Settings",
"auto_hide_description": "Automatically blind posts/comments when reports exceed a threshold.",
"report_abuse_prevention": "Report Restriction Settings",
"report_abuse_prevention_description": "Limit daily reports per user and block reporting when rejections accumulate. Applied globally across all boards.",
"spam_security": "Security Settings",
"spam_security_description": "Configure cooldowns and view count security settings",
"report_notification": "Report Notification",
"report_notification_description": "Configure which administrators receive notifications and how notifications are sent when a report is submitted.",
"report_permissions": "Report Management Permissions",
"report_permissions_description": "Assign roles that can view the report list and process reports (approve, reject, or blind). Admin notifications for new reports are sent based on the 'Process Reports Permission' role."
},
"fields": {
"type": "Board Type",
"secret_mode": "Secret Mode",
"per_page": "Posts per Page (PC)",
"per_page_mobile": "Posts per Page (Mobile)",
"order_by": "Default Sort By",
"order_direction": "Default Sort Direction",
"new_display_hours": "New Post Display Period",
"new_display_hours_hint": "Duration to display [New] badge after posting (1-720)",
"show_view_count": "Show View Count",
"min_title_length": "Min Title Length",
"max_title_length": "Max Title Length",
"min_content_length": "Minimum Content Length",
"max_content_length": "Maximum Content Length",
"use_reply": "Enable Reply Posts",
"max_reply_depth": "Max Reply Depth",
"max_reply_depth_hint": "Enter a value between 1 and 5",
"use_comment": "Enable Comments",
"comment_order": "Comment Sort Order",
"min_comment_length": "Min Comment Length",
"max_comment_length": "Max Comment Length",
"max_comment_depth": "Max Comment Depth",
"max_comment_depth_hint": "Enter a value between 0 and 10. Setting to 0 disables the nested comment feature.",
"use_file_upload": "File Upload Settings",
"max_file_size": "Maximum File Size (MB)",
"max_file_size_hint": "Enter in MB",
"max_file_count": "Max File Count",
"allowed_extensions": "Allowed Extensions",
"allowed_extensions_placeholder": "Type extension and press Enter (e.g., jpg, png, pdf)",
"notify_admin_on_post": "Admin Notification",
"notify_admin_on_post_description": "Send email to admin when a post is submitted",
"notify_author": "Author Notification",
"notify_author_description": "Send email to the original author when a comment or reply is posted",
"use_report": "Enable Reports",
"auto_hide_threshold": "Auto Blind Threshold",
"auto_hide_threshold_hint": "Automatically blind content when reports reach this count. (0=disabled, 1\u2013100)",
"auto_hide_target": "Blind Target",
"auto_hide_target_hint": "Select the target for auto blind processing.",
"daily_report_limit": "Daily Report Limit",
"daily_report_limit_hint": "Max reports per user per day across all boards. 0 = no limit. (0\u2013100)",
"rejection_limit_count": "Rejection Limit (count)",
"rejection_limit_days": "Rejection Limit Period (days)",
"rejection_limit_hint": "Reporting will be blocked when rejections exceed the limit within this period. 0 count = no limit. (Count: 0\u201350, Period: 1\u2013365 days)",
"report_cooldown_seconds": "Report Cooldown (sec)",
"report_cooldown_seconds_hint": "Minimum interval between consecutive reports. 0 = no limit. (0\u20133600 sec)",
"notify_author_on_report_action": "Report Action Notification",
"notify_author_on_report_action_description": "Send a notification to the author when their content is processed (blinded/deleted/restored) due to reports",
"notify_admin_on_report": "Report Received Admin Notification",
"notify_admin_on_report_description": "Sends a notification to administrators with the 'Process Reports Permission' when a report is submitted. The number of notifications sent depends on the scope setting below.",
"notify_admin_on_report_scope": "Notification Scope",
"notify_admin_on_report_scope_per_case": "First report only (per post/comment)",
"notify_admin_on_report_scope_per_report": "Every time a report is submitted",
"report_view_roles": "View Reports Permission",
"report_view_roles_description": "Permission to view the report list and report details.",
"report_manage_roles": "Process Reports Permission",
"report_manage_roles_description": "Permission to approve, reject, or blind reports. Admin notifications on report submission are also sent to roles with this permission.",
"blocked_keywords": "Restricted Keywords",
"blocked_keywords_placeholder": "Type keyword and press Enter",
"blocked_keywords_description": "List of keywords to block in posts and comments",
"blocked_keywords_hint": "Posts or comments containing registered keywords will be blocked",
"post_cooldown_seconds": "Post Cooldown (sec)",
"post_cooldown_seconds_hint": "Wait time between consecutive posts by the same user (0\u20133600 sec, 0 = no limit)",
"comment_cooldown_seconds": "Comment Cooldown (sec)",
"comment_cooldown_seconds_hint": "Wait time between consecutive comments by the same user (0\u20133600 sec, 0 = no limit)",
"spam_security_identifier_info": "Cooldown and view count deduplication identify members by user_id and guests by IP address",
"view_count_cache_ttl": "View Count Cache TTL (sec)",
"view_count_cache_ttl_hint": "Cache duration for view count deduplication (60\u2013604800 sec, max 7 days)",
"date_display_format": "Date Display Format",
"date_display_format_hint": "Choose how dates are displayed in post and comment lists. Applied immediately across all boards on the user-facing pages.",
"clear_cache": "Clear Cache",
"clear_cache_description": "Clear all board-related caches",
"clear_cache_success": "Cache has been cleared.",
"clear_cache_error": "Failed to clear cache.",
"descriptions": {
"type": "Set the default display format for new boards",
"secret_mode": "Set the default behavior for the secret post feature",
"show_view_count": "Display view count on post list and detail pages",
"use_report": "Enable reporting of inappropriate posts and comments",
"new_display_hours": "Duration to display [New] badge after posting (in hours)",
"per_page": "Number of posts to display per page on desktop",
"per_page_mobile": "Number of posts to display per page on mobile",
"order_by": "Set the default sort criteria for the post list",
"order_direction": "Set the default sort direction for the post list",
"min_title_length": "Set the minimum character count for post titles",
"max_title_length": "Set the maximum character count for post titles",
"min_content_length": "Set the minimum character count for post content",
"max_content_length": "Set the maximum character count for post content",
"use_reply": "Must be ON to apply reply post feature to new boards.",
"max_reply_depth": "Set the maximum nesting depth for replies (1-5)",
"blocked_keywords": "Posts or comments containing registered keywords will be blocked",
"use_comment": "Must be ON to apply comment feature to new boards. Comment detail settings below will also apply.",
"comment_order": "Set the default sort direction for comments",
"min_comment_length": "Set the minimum character count for comments",
"max_comment_length": "Set the maximum character count for comments",
"max_comment_depth": "Set the maximum nesting depth for nested comments. Setting to 0 disables the nested comment feature (0-10)",
"use_file_upload": "Must be ON to apply file attachment feature to new boards.",
"max_file_size": "Maximum file size for uploads (in MB)",
"max_file_count": "Maximum number of files per post",
"allowed_extensions": "Leave empty to allow all file extensions"
}
},
"options": {
"date_display_format": {
"standard": "Standard",
"standard_description": "Recent posts show as 'N min ago / N hours ago', this year as 'MM-DD', older as 'YY-MM-DD'",
"standard_example": "e.g. 3 min ago \u00b7 2 hours ago \u00b7 03-18 \u00b7 24-12-25",
"relative": "Relative",
"relative_description": "All dates shown as relative time (N min \u00b7 N hours \u00b7 N days \u00b7 N months \u00b7 N years ago)",
"relative_example": "e.g. 3 min ago \u00b7 2 hours ago \u00b7 5 days ago \u00b7 3 months ago \u00b7 2 years ago"
},
"type": {
"basic": "Basic",
"gallery": "Gallery",
"card": "Card"
},
"secret_mode": {
"disabled": "Disabled",
"enabled": "Optional",
"always": "Always Secret"
},
"order_by": {
"created_at": "Latest",
"view_count": "View Count",
"title": "Title",
"author": "Author"
},
"order_direction": {
"DESC": "Descending",
"ASC": "Ascending"
},
"comment_order": {
"ASC": "Ascending (Oldest first)",
"DESC": "Descending (Newest first)"
},
"auto_hide_target": {
"post": "Posts only",
"comment": "Comments only",
"both": "Posts + Comments"
}
},
"permissions_table": {
"title": "Default Permissions",
"description": "Set which roles can access each permission.",
"bulk_apply_notice": "When bulk applying, the selected permissions will replace the existing roles of each board with the current settings. However, the manager and step roles of each board are preserved as-is.",
"admin_section": "Admin Permissions",
"user_section": "General Permissions",
"role_admin": "Admin",
"role_user": "User",
"role_guest": "Guest",
"header_permission": "Permission",
"header_admin": "Admin",
"header_user": "User",
"header_guest": "Guest",
"select_permissions": "Select permissions",
"select_roles": "Select roles",
"permissions": {
"admin.posts.read": "View Posts",
"admin.posts.write": "Create/Edit/Delete Posts",
"admin.posts.read-secret": "View Secret Posts",
"admin.comments.read": "View Comments",
"admin.comments.write": "Create/Edit/Delete Comments",
"admin.attachments.upload": "Upload/Delete Files",
"admin.attachments.download": "Download Files",
"admin.manage": "Manage Board",
"posts.read": "View Posts",
"posts.write": "Create/Edit/Delete Posts",
"posts.read-secret": "View Secret Posts",
"comments.read": "View Comments",
"comments.write": "Create/Edit/Delete Comments",
"attachments.upload": "Upload/Delete Files",
"attachments.download": "Download Files",
"manager": "Manage Board"
}
},
"notification_channels": {
"channel_mail": "Email",
"channel_database": "Site Notification",
"channels_label": "Notification Channels"
},
"bulk_apply": {
"title": "Bulk Apply to Existing Boards",
"description": "Apply the above settings to existing boards. Select items and target boards to apply.",
"select_fields": "Select Fields to Apply",
"select_all": "Select All",
"target_boards": "Target Boards",
"apply_all": "All Boards",
"apply_selected": "Selected Boards",
"select_boards_placeholder": "Select boards",
"apply_button": "Bulk Apply",
"no_fields_selected": "No items selected.",
"no_boards_selected": "No boards selected.",
"checked_fields_notice": "All settings will be saved, then only the checked items will be bulk applied.",
"no_boards_notice": "No boards available for bulk apply. Please create a board first.",
"field_labels": {
"type": "Default Type",
"per_page": "Posts per Page (PC)",
"per_page_mobile": "Posts per Page (Mobile)",
"order_by": "Default Sort By",
"order_direction": "Default Sort Direction",
"secret_mode": "Secret Mode",
"use_comment": "Enable Comments",
"use_reply": "Enable Replies",
"max_reply_depth": "Max Reply Depth",
"max_comment_depth": "Max Comment Depth",
"comment_order": "Comment Sort Order",
"show_view_count": "Show View Count",
"use_report": "Enable Reports",
"min_title_length": "Min Title Length",
"max_title_length": "Max Title Length",
"min_content_length": "Min Content Length",
"max_content_length": "Max Content Length",
"min_comment_length": "Min Comment Length",
"max_comment_length": "Max Comment Length",
"use_file_upload": "Enable File Upload",
"max_file_size": "Max File Size",
"max_file_count": "Max File Count",
"allowed_extensions": "Allowed Extensions",
"notify_admin_on_post": "Admin Notification",
"notify_author": "Author Notification",
"new_display_hours": "New Post Display Hours",
"default_board_permissions": "Default Permissions",
"manager": "Manage Board"
}
},
"bulk_apply_modal": {
"title": "Confirm Bulk Apply",
"message": "All settings will be saved, then the checked items will be bulk applied. This action cannot be undone.",
"message_all": "All settings will be saved, then the checked items will be bulk applied. This action cannot be undone.",
"all_boards": "All Boards",
"warning": "This action cannot be undone. Existing settings of the target boards will be overwritten.",
"confirm": "Save & Apply",
"success": "Settings applied to {{count}} boards.",
"error": "Bulk apply failed.",
"selected_fields": "Items to Apply",
"selected_boards": "Boards to Apply",
"no_fields": "No items selected.",
"no_boards": "No boards selected.",
"processing": "Applying...",
"select_boards_required": "Please select at least one board.",
"save_and_apply_info": "Upon confirmation, all settings in the current tab will be saved, and only the checked items will be bulk applied to the selected boards.",
"save_error": "Failed to save settings. Bulk apply has been cancelled.",
"permission_notice": "The selected permission items will replace the existing roles of each board with the current settings. However, the manager and step roles of each board are preserved as-is."
},
"seo": {
"meta_settings": {
"title": "Meta Settings",
"description": "Set the title and description for each page type to be displayed in search engines.",
"meta_title": "Meta Title",
"meta_description": "Meta Description",
"available_variables": "Available variables"
},
"boards_page": {
"title": "Board List Page",
"title_placeholder": "{site_name} Boards"
},
"board_page": {
"title": "Board Posts Page",
"title_placeholder": "{board_name}"
},
"post_page": {
"title": "Post Detail Page",
"title_placeholder": "{board_name} - {post_title}"
},
"seo_friendly": {
"title": "SEO Pages",
"description": "Select which pages to provide HTML to search bots.",
"provide_pages": "Pages to provide SEO",
"boards": "Board List Page",
"board": "Board Posts Page",
"post_detail": "Post Detail Page",
"cache_management": "SEO Cache Management",
"cache_description": "Manually clear the SEO cache for board pages. The cache is automatically cleared when settings are saved.",
"clear_cache": "Clear SEO Cache",
"clear_cache_success": "SEO cache has been cleared.",
"clear_cache_error": "Failed to clear SEO cache."
}
},
"notification_definitions": {
"title": "Notification Settings",
"description": "Manage notification templates by channel for the board module.",
"channel_management": "Notification Channels",
"active": "Active",
"inactive": "Inactive",
"default_badge": "Default",
"btn_edit": "Edit",
"btn_reset": "Reset to Default",
"toggle_success": "Notification active status has been changed.",
"reset_success": "Notification settings have been reset to default.",
"reset_failed": "Failed to reset notification settings.",
"reset_modal": {
"title": "Reset to Default",
"confirm_message": "All channel templates of this notification will be restored to defaults.",
"warning_message": "The saved subject, body, and recipient settings will be permanently lost.",
"target_label": "Target:",
"btn_confirm": "Reset",
"btn_confirming": "Resetting..."
},
"no_template_for_channel": "No template for this channel.",
"body_preview": "Body Preview",
"total_count": "Total {{count}} items",
"per_page_label": "Per page",
"empty_message": "No notification settings found.",
"types": {
"new_comment": "New Comment",
"reply_comment": "Reply Comment",
"post_reply": "Post Reply",
"post_action": "Admin Action",
"new_post_admin": "New Post (Admin)",
"report_received_admin": "Report Received (Admin)",
"report_action": "Report Action Result"
},
"save_success": "Notification template has been saved.",
"save_error": "Failed to save notification template.",
"edit_modal": {
"title": "Edit Notification Template",
"type_label": "Type:",
"channel_label": "Channel:",
"subject": "Subject",
"body": "Body",
"variables_title": "Available Variables",
"btn_preview": "Preview",
"validation_error": "Please check your input."
},
"preview_modal": {
"title": "Notification Preview",
"subject_label": "Subject",
"body_label": "Body"
}
}
}
@@ -0,0 +1,13 @@
{
"form": {
"sections": {
"notification_settings": "Notification Settings"
},
"notifications": {
"post_complete": "Post Complete Notification",
"post_reply": "Post Reply Notification",
"comment": "Comment Notification",
"reply_comment": "Reply Comment Notification"
}
}
}
@@ -94,14 +94,6 @@
"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.",
@@ -750,7 +750,7 @@
},
"search_fields": {
"all": "전체",
"title": "게시글 제목",
"title_content": "제목+내용",
"author": "작성자(이름, 이메일)"
},
"filters": {
@@ -839,7 +839,7 @@
"basic_defaults": "게시판 설정",
"report_policy": "신고 정책",
"spam_security": "보안 설정",
"mail_templates": "메일 템플릿"
"notification_definitions": "알림 설정"
},
"board_settings_tabs": {
"basic": "기본",
@@ -1104,28 +1104,26 @@
"max_file_count": "최대 파일 개수",
"allowed_extensions": "허용 확장자",
"notify_admin_on_post": "관리자 알림",
"notify_admin_on_post_channels": "관리자 알림 채널",
"notify_author": "작성자 알림",
"notify_author_channels": "작성자 알림 채널",
"new_display_hours": "새글 표시 시간",
"default_board_permissions": "기본 권한",
"manager": "게시판 관리"
}
},
"mail_templates": {
"title": "메일 템플릿 관리",
"description": "게시판 알림 이메일의 템플릿을 관리합니다. 제목과 본문을 수정하고 활성/비활성 상태를 제어할 수 있습니다.",
"notification_definitions": {
"title": "알림 설정",
"description": "게시판 알림의 제목과 본문을 관리합니다. 활성/비활성 상태를 제어할 수 있습니다.",
"active": "활성",
"inactive": "비활성",
"default_badge": "기본",
"btn_activate": "활성화",
"btn_deactivate": "비활성화",
"btn_edit": "편집",
"btn_reset": "초기화",
"toggle_success": "메일 템플릿 상태가 변경되었습니다.",
"reset_success": "메일 템플릿이 기본값으로 복원되었습니다.",
"save_success": "메일 템플릿이 저장되었습니다.",
"save_error": "메일 템플릿 저장에 실패했습니다.",
"btn_reset": "기본값 복원",
"toggle_success": "알림 활성 상태가 변경되었습니다.",
"reset_success": "알림 설정이 기본값으로 복원되었습니다.",
"save_success": "알림 설정이 저장되었습니다.",
"save_error": "알림 설정 저장에 실패했습니다.",
"types": {
"new_comment": "새 댓글 알림",
"reply_comment": "대댓글 알림",
@@ -1143,20 +1141,21 @@
"search_type_body": "본문",
"total_count": "총 {{count}}건",
"per_page_label": "페이지당",
"empty_message": "등록된 메일 템플릿이 없습니다.",
"empty_message": "등록된 알림 설정이 없습니다.",
"empty_no_results": "검색 결과가 없습니다.",
"edit_modal": {
"title": "메일 템플릿 편집",
"title": "알림 템플릿 편집",
"type_label": "유형:",
"subject": "제목",
"body": "본문",
"channels_label": "활성 채널",
"variables_title": "사용 가능한 변수",
"btn_preview": "미리보기",
"validation_error": "입력 정보를 확인해주세요.",
"is_active_label": "활성화"
},
"preview_modal": {
"title": "메일 미리보기",
"title": "알림 미리보기",
"subject_label": "제목",
"body_label": "본문"
}
@@ -0,0 +1,51 @@
{
"index": {
"title": "게시판 관리",
"description": "게시판 목록을 관리합니다",
"search_placeholder": "게시판 검색...",
"search": "검색",
"add_board": "게시판 추가",
"post_count": "게시글:",
"created_at": "생성일:",
"categories_label": "분류:",
"empty_title": "게시판이 없습니다",
"empty_description": "등록된 게시판이 없습니다.",
"filter_all_types": "전체 유형",
"filter_type_basic": "기본형",
"filter_type_gallery": "갤러리형",
"filter_type_card": "카드형",
"status": "상태",
"active": "활성화",
"inactive": "비활성화"
},
"create": {
"title": "게시판 생성"
},
"edit": {
"title": "게시판 수정"
},
"columns": {
"type": "유형",
"name": "게시판명",
"slug": "슬러그",
"posts_count": "게시글 수",
"created_at": "생성일"
},
"actions": {
"edit": "수정",
"manage_posts": "게시글 관리",
"copy": "복사",
"delete": "삭제"
},
"messages": {
"create_success": "게시판이 생성되었습니다",
"update_success": "게시판이 수정되었습니다",
"delete_success": "게시판이 삭제되었습니다",
"deleted": "게시판이 삭제되었습니다",
"copy_success": "게시판이 복사되었습니다",
"save_failed": "저장에 실패하였습니다",
"table_not_found": "게시판 테이블이 존재하지 않아 삭제할 수 없습니다",
"activated": "게시판이 활성화되었습니다",
"deactivated": "게시판이 비활성화되었습니다"
}
}
@@ -0,0 +1,19 @@
{
"manage": "관리",
"modal_title": "게시판 유형 관리",
"slug": "슬러그",
"name": "이름",
"actions": "작업",
"empty": "등록된 유형이 없습니다",
"delete_confirm": "이 유형을 삭제하시겠습니까?",
"delete_success": "유형이 삭제되었습니다",
"edit_title": "유형 수정",
"add_title": "유형 추가",
"add": "유형 추가",
"adding": "추가 중...",
"name_placeholder": "예: 기본형",
"slug_hint": "소문자, 숫자, 하이픈(-)만 사용 가능하며 소문자로 시작해야 합니다. (예: basic, gallery, photo-card)",
"update_success": "유형이 수정되었습니다",
"create_success": "유형이 생성되었습니다",
"usage_guide": "슬러그(slug)는 게시판의 표시 유형을 구분하는 식별자입니다. 템플릿에서 해당 슬러그에 맞는 전용 레이아웃을 추가 및 설정해야 적용됩니다."
}
@@ -0,0 +1,15 @@
{
"inquiry": {
"description": "상품 문의게시판으로 사용할 게시판을 지정할 수 있습니다.",
"board_label": "문의 게시판",
"board_placeholder": "게시판을 선택하세요",
"hint_secret": "ℹ 비밀글 기능이 활성화된 게시판을 권장합니다.",
"go_to_board_edit": "게시판 수정 페이지로 이동",
"go_to_board_edit_desc": "비밀글 설정, 읽기/쓰기 권한, 카테고리 등 게시판 상세 설정을 변경할 수 있습니다.",
"hint_category": "ℹ 게시판에 분류를 설정하면 문의 작성 시 분류를 선택할 수 있습니다.",
"go_to_posts": "문의 내역 관리",
"go_to_posts_desc": "고객이 등록한 상품 문의를 확인하고 답변을 관리할 수 있습니다. 관리자 사이드바 메뉴에 추가하면 더 편리하게 접근할 수 있습니다.",
"add_to_admin_menu": "관리자 메뉴에 추가",
"add_to_admin_menu_desc": "사이드바 관리자 메뉴에 이 게시판의 게시글 관리 항목을 추가합니다."
}
}
@@ -0,0 +1,222 @@
{
"title_create": "게시판 생성",
"title_edit": "게시판 수정",
"description": "게시판 설정을 관리합니다",
"read_only_message": "보기 권한만 있습니다. 수정이 필요한 경우 관리자에게 문의하세요.",
"validation_error": "입력 정보를 확인해주세요.",
"add_category": "분류 추가",
"tabs": {
"basic": "기본 설정",
"permissions": "권한 설정",
"list": "목록 설정",
"post": "게시글 설정",
"notification": "알림 설정"
},
"fields": {
"name": "게시판명",
"slug": "슬러그",
"description": "게시판 설명",
"is_active": "활성화",
"type": "게시판 유형",
"categories": "분류",
"show_view_count": "조회수 표시",
"use_comment": "댓글 사용",
"use_reply": "답변글 사용",
"use_report": "신고 사용",
"secret_mode": "비밀글 모드",
"order_by": {
"label": "정렬 기준",
"options": {
"created_at": "최신글",
"view_count": "조회수",
"title": "제목",
"author": "글쓴이"
}
},
"order_direction": {
"label": "정렬 순서",
"options": {
"desc": "내림차순",
"asc": "오름차순"
}
},
"per_page": {
"label": "한 페이지당 목록 수 (PC)",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"per_page_mobile": {
"label": "한 페이지당 목록 수 (Mobile)",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"min_title_length": {
"label": "최소 제목 글자 수",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"max_title_length": {
"label": "최대 제목 글자 수",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"min_content_length": {
"label": "최소 게시글 글자 수",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"max_content_length": {
"label": "최대 게시글 글자 수",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"min_comment_length": {
"label": "최소 댓글 글자 수",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"max_comment_length": {
"label": "최대 댓글 글자 수",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"blocked_keywords": {
"label": "제한 키워드",
"placeholder": "쉼표로 구분하여 입력 (예: 욕설, 비방, 광고)",
"description": "부적절한 단어 또는 해당 게시판에 제외될 키워드"
},
"use_file_upload": {
"label": "첨부 파일 설정",
"description": "게시글에 파일을 첨부할 수 있습니다."
},
"max_file_count": {
"label": "최대 파일 개수",
"hint": "최대 {{max}}개까지 업로드 가능합니다"
},
"max_file_size": {
"label": "최대 파일 용량 (MB)",
"placeholder": "MB 단위로 입력",
"hint": "최대 {{maxMB}}MB까지 업로드 가능합니다"
},
"allowed_extensions": {
"label": "허용 확장자",
"placeholder": "쉼표로 구분하여 입력 (예: jpg, png, pdf)",
"description": "빈 값일 경우 모든 확장자 허용"
},
"comment_order": {
"label": "댓글 정렬 순서",
"options": {
"asc": "오름차순 (오래된순)",
"desc": "내림차순 (최신순)"
}
},
"new_display_hours": {
"label": "신규 게시글 표시 기간",
"hint": "게시글 작성 후 [New] 배지를 표시할 기간 (시간 단위)"
},
"max_reply_depth": {
"label": "답변글 최대 깊이",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"max_comment_depth": {
"label": "대댓글 최대 깊이",
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요. 0으로 설정하면 대댓글 기능이 비활성화됩니다."
},
"notify_author": {
"label": "작성자 이메일 알림",
"description": "댓글, 대댓글, 답변글, 관리자 처리 시 작성자에게 이메일 발송"
},
"notify_admin_on_post": {
"label": "관리자 이메일 알림",
"description": "게시글이 등록되었을 경우 관리자에게 이메일 발송"
},
"notification_channels": {
"label": "알림 채널",
"description": "게시판 알림을 수신할 채널을 선택합니다",
"channels_label": "알림 채널",
"channel_mail": "이메일",
"channel_database": "사이트 알림"
}
},
"placeholders": {
"name": "예: 공지사항",
"slug": "예: notice (영문, 숫자, 하이픈만 가능)",
"description": "게시판의 용도를 간략하게 설명하세요",
"select_type": "게시판 유형을 선택하세요"
},
"descriptions": {
"slug": "URL 및 테이블명으로 사용됩니다 (영문, 숫자, 하이픈만 가능)",
"description": "게시판 목록에서 사용자에게 표시되는 설명입니다",
"is_active": "비활성화된 게시판은 사용자 페이지에 표시되지 않습니다",
"categories": "게시글 분류를 관리합니다",
"categories_notice": "분류를 삭제해도 해당 분류의 게시글은 유지되며, '미분류' 필터에서 조회할 수 있습니다.",
"show_view_count": "게시글 목록 및 상세에서 조회수를 표시합니다",
"use_comment": "활성화하면 댓글 관련 세부 설정(길이, 깊이, 정렬)을 지정할 수 있습니다.",
"use_reply": "활성화하면 답변글 최대 깊이를 설정할 수 있습니다.",
"use_report": "부적절한 게시글/댓글 신고 기능을 활성화합니다",
"type": "게시판 유형은 환경설정 > 기본설정에서 관리할 수 있습니다."
},
"options": {
"secret_mode": {
"disabled": "사용 안함",
"enabled": "선택 가능",
"always": "항상 비밀글"
}
},
"permissions": {
"managers_title": "게시판 관리자",
"managers_description": "게시판의 모든 권한을 가진 관리자를 지정합니다.",
"managers_select_button": "관리자 선택",
"manager_email_placeholder": "관리자 이메일 입력",
"search_manager": "조회",
"confirm_add": "추가",
"select": "선택",
"change": "변경",
"users_found": "{{count}}명의 사용자를 찾았습니다",
"no_users_found_search": "사용자를 찾을 수 없습니다",
"user_search_failed": "사용자 조회에 실패했습니다",
"users_found_with_limit": "{{count}}명의 사용자를 찾았습니다 (최대 20명까지 표시)",
"user_added": "{{name}}이(가) 추가되었습니다",
"no_search_results": "검색 결과가 없습니다",
"selected_managers": "선택된 관리자",
"select_managers": "관리자 선택",
"admin_permissions_title": "관리자 권한 (Admin)",
"admin_permissions_description": "관리자 페이지 접근 권한입니다. 게시판 관리자 및 시스템 관리자에게 부여됩니다.",
"user_permissions_title": "사용자 권한 (User)",
"user_permissions_description": "일반 사용자의 게시판 접근 권한입니다. 역할(Role)을 통해 세부 권한을 제어합니다.",
"settings_title": "역할별 접근 제어",
"settings_description": "각 권한에 할당할 역할을 선택하세요. 역할이 선택되지 않으면 모든 사용자에게 허용됩니다.",
"search_users": "이름 또는 이메일로 검색",
"no_users_found": "검색 결과가 없습니다",
"select_roles": "역할을 선택해주세요 (필수)",
"mode_all": "전체",
"mode_roles": "역할",
"header_feature": "기능",
"header_access": "접근 권한",
"header_all_access": "전체 허용",
"header_role_selection": "역할 선택",
"all_access": "전체 허용",
"all_access_enabled": "모든 사용자가 접근할 수 있습니다.",
"role_restricted": "역할 제한",
"select_roles_hint": "접근을 허용할 역할을 선택하세요. (필수)",
"admin_count": "{{count}}개 권한",
"user_count": "{{count}}개 권한",
"error_title": "권한 설정 오류",
"error_description": "각 권한에 최소 하나 이상의 역할을 선택해주세요.",
"error_roles_required": "역할을 선택해주세요.",
"select_manager": "관리자 선택",
"search_placeholder": "이름 또는 이메일 검색 (최대 20명까지 표시되니 구체적으로 입력해주세요)",
"steps_title": "게시판 스텝",
"steps_description": "스텝은 게시판 관리 권한을 제외한 모든 권한을 가집니다.",
"auto_role_notice": "게시판 관리자와 스텝 역할은 생성 시 각 권한에 기본값으로 포함되며, 아래에서 수정할 수 있습니다.",
"create_mode_permissions_notice": "역할별 접근 제어는 게시판 생성 후 수정 화면에서 설정할 수 있습니다. 생성 시에는 기본 권한이 자동으로 적용됩니다."
},
"modals": {
"category_remove": {
"title": "분류 삭제",
"warning": "이 작업은 되돌릴 수 없습니다",
"message": "'{{category}}' 분류를 삭제하시겠습니까?"
}
},
"menu": {
"manual_guide": "메뉴 관리에서 아래 경로를 입력하여 등록하세요.",
"manual_admin_label": "관리자 메뉴 경로",
"manual_user_label": "사용자 메뉴 경로",
"example": "예시",
"add_to_admin_menu": "관리자 메뉴에 추가",
"menu_added_success": "관리자 메뉴에 추가되었습니다.",
"menu_add_failed": "메뉴 추가에 실패했습니다."
}
}
@@ -0,0 +1,26 @@
{
"copy": {
"title": "게시판 복사",
"message": "'{{name}}' 게시판을 복사하시겠습니까?",
"info": "※ 게시판 설정만 복사되며, 게시글은 복사되지 않습니다.",
"new_slug_label": "새 슬러그",
"new_slug_placeholder": "예: notice-copy",
"confirm": "복사"
},
"delete": {
"title": "게시판 삭제",
"confirm": "'{{name}}' 게시판을 삭제하시겠습니까?",
"with_posts_info": "이 게시판에는 {{posts_count}}개의 게시글과 댓글이 있습니다.",
"no_posts_info": "이 게시판에는 게시글이 없습니다.",
"force_delete_label_with_posts": "영구 삭제 (게시글, 댓글, 첨부파일 모두 삭제)",
"force_delete_label_no_posts": "영구 삭제 (데이터베이스 테이블도 삭제)",
"force_delete_warning_with_posts": "영구 삭제 시 모든 데이터가 복구 불가능하게 제거됩니다. 삭제 전에 데이터를 백업하세요.",
"force_delete_warning_no_posts": "이 작업은 되돌릴 수 없습니다. 게시판과 관련된 모든 데이터가 완전히 제거됩니다.",
"backup_notice": "데이터 손실을 방지하기 위해 삭제 전에 데이터베이스를 백업하시기를 권장합니다."
},
"cancel_confirm": {
"title": "변경사항 확인",
"message": "저장하지 않은 변경사항이 있습니다. 페이지를 떠나시겠습니까?",
"confirm": "페이지 나가기"
}
}
@@ -0,0 +1,284 @@
{
"badge": {
"notice": "[공지]"
},
"page_title": "게시글 관리",
"page_description": "게시판 게시글을 관리합니다",
"page_description_with_board": "의 게시글을 관리합니다",
"add_post": "게시글 추가",
"search_placeholder": "게시글 검색...",
"advanced_search": "상세 검색",
"search": "검색",
"reset": "초기화",
"create": {
"title": "게시글 작성"
},
"reply": {
"title": "답변글 작성"
},
"list": {
"reply": "답변하기",
"notice": "공지"
},
"detail": {
"notice": "공지",
"title": "게시글 상세",
"description": "게시글 상세 정보를 확인합니다",
"reply_button": "답변하기",
"view_detail_button": "상세보기",
"report": "신고",
"reported": "신고됨",
"view_count": "조회 {{count}}",
"attachments": "첨부파일 ({{count}})",
"prev": "이전",
"next": "다음",
"comments_title": "댓글 ({{count}})",
"no_comments": "아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!",
"comment_placeholder": "댓글을 입력하세요...",
"comment_submit": "댓글 등록",
"comment_submitting": "등록 중",
"comment_submit_success": "댓글이 등록되었습니다.",
"comment_edit_success": "댓글이 수정되었습니다.",
"comment_reply": "답변 등록",
"reply": "답변",
"reply_submitting": "등록 중",
"reply_placeholder": "답변을 입력하세요...",
"replying_to": "{{name}}님에게 답변 작성 중...",
"reply_submit_success": "답변이 등록되었습니다.",
"comment_added": "댓글이 등록되었습니다.",
"guest_password_verify": "비밀번호 확인",
"guest_password_verify_message": "비회원 글을 수정하시려면 비밀번호를 입력해주세요.",
"password_placeholder": "비밀번호를 입력하세요",
"password_incorrect": "비밀번호가 일치하지 않습니다.",
"show_replies": "답변글 {{count}}개 보기",
"hide_replies": "답변글 숨기기",
"reply_blocked_blinded_post": "블라인드 처리된 게시글에는 답변글을 작성할 수 없습니다.",
"reply_blocked_deleted_post": "삭제된 게시글에는 답변글을 작성할 수 없습니다.",
"user_info": "사용자 정보",
"admin_badge": "관리자",
"cancel": "취소",
"edit": "수정",
"replies_title": "답변 ({{count}})",
"reply_secret_content": "비밀글입니다",
"reply_view_detail": "자세히 보기",
"load_more_comments": "이전 댓글 {{count}}개 더보기",
"status_blinded": "블라인드",
"status_deleted": "삭제됨",
"status_published": "게시중",
"action_logs_title": "관리 이력",
"action_logs_empty": "관리 이력이 없습니다.",
"action_log_blind": "블라인드 처리",
"action_log_delete": "삭제 처리",
"action_log_restore": "복원 처리",
"action_log_by": "처리자:",
"action_log_reason": "사유:",
"action_log_at": "처리일:",
"blinded_notice": "블라인드 처리된 게시글입니다",
"blinded_description": "운영 정책에 따라 게시글이 숨김 처리되었습니다",
"blinded_comment_notice": "블라인드 처리된 댓글입니다",
"blinded_comment_description": "운영 정책에 따라 댓글이 숨김 처리되었습니다",
"deleted_comment_notice": "삭제된 댓글입니다",
"blind_reason_label": "사유:",
"blinded_at": "블라인드 처리일: {{date}}",
"view_original_content": "원본 내용 보기",
"original_content_label": "원본 내용",
"hide_original_content": "원본 숨기기",
"restore": "복원",
"restore_confirm_title": "게시글 복원",
"restore_confirm_message": "이 게시글을 복원하시겠습니까?",
"restore_success": "게시글이 복원되었습니다.",
"parent_post_title": "원글",
"parent_title_label": "제목",
"parent_author_label": "작성자",
"parent_date_label": "작성일",
"view_parent_post": "원글 보기",
"deleted_at": "삭제일: {{date}}",
"delete_modal": {
"title_post": "게시글 삭제",
"title_reply": "답변 삭제",
"title_comment": "댓글 삭제",
"confirm_post": "게시글을 삭제하시겠습니까?",
"confirm_reply": "답변 게시글을 삭제하시겠습니까?",
"confirm_comment": "댓글을 삭제하시겠습니까?",
"warning_comment": "삭제된 댓글은 복구할 수 없습니다.",
"warning_reply": "삭제된 답변은 복구할 수 없습니다.",
"success_post": "게시글이 삭제되었습니다.",
"success_reply": "답변이 삭제되었습니다.",
"success_comment": "댓글이 삭제되었습니다.",
"button_delete": "삭제",
"error": "삭제에 실패했습니다."
},
"blind_modal": {
"title": "블라인드 처리",
"title_comment": "댓글 블라인드 처리",
"reason_label": "블라인드 사유",
"reason_placeholder": "블라인드 처리 사유를 입력하세요 (최소 10자)",
"submit": "블라인드 처리",
"success": "블라인드 처리되었습니다.",
"success_comment": "댓글이 블라인드 처리되었습니다."
},
"restore_modal": {
"title": "복원",
"title_comment": "댓글 복원",
"reason_label": "복원 사유 (선택)",
"reason_placeholder": "복원 사유를 입력하세요",
"submit": "복원",
"success": "복원되었습니다.",
"success_comment": "댓글이 복원되었습니다."
},
"report_modal": {
"title": "게시글 신고",
"title_comment": "댓글 신고",
"warning_notice": "신고는 취소가 불가능하니 신중하게 확인 후 신고해주세요. 신고하시겠습니까?",
"reason_type_label": "신고 유형",
"reason_type_placeholder": "신고 유형을 선택하세요",
"reason_type_required": "신고 유형을 선택하세요.",
"reason_detail_label": "상세 사유",
"reason_label": "신고 사유",
"reason_placeholder": "신고 사유를 구체적으로 입력하세요",
"reason_required": "상세 사유를 입력하세요.",
"submit": "신고 제출",
"success": "신고가 접수되었습니다.",
"success_comment": "댓글 신고가 접수되었습니다.",
"warning": "⚠️ 신고는 취소할 수 없습니다. 신중히 결정해주세요.",
"error": "신고 접수에 실패했습니다.",
"report_target": "신고 대상: ",
"comment_by_author": "{{author}}님의 댓글"
}
},
"form": {
"description": "게시글을 작성하거나 수정합니다",
"read_only_message": "보기 권한만 있습니다. 수정이 필요한 경우 관리자에게 문의하세요.",
"author_info": "작성자",
"author": "작성자명",
"email": "이메일",
"created_at": "작성일",
"is_notice": "공지사항",
"is_secret": "비밀글",
"secret_post_always": "이 게시판은 항상 비밀글로 작성됩니다",
"notice_description": "이 글을 게시판 상단에 고정합니다",
"secret_description": "작성자와 관리자만 볼 수 있습니다",
"category": "분류",
"select_category": "분류 선택",
"title": "제목",
"title_placeholder": "게시글 제목을 입력하세요",
"content": "내용",
"content_placeholder": "게시글 내용을 입력하세요",
"html_mode": "HTML 모드",
"show_preview": "미리보기",
"hide_preview": "미리보기 닫기",
"preview_title": "HTML 미리보기",
"edit_mode": "편집",
"preview_mode": "미리보기",
"preview_html": "HTML 미리보기",
"html_preview_warning": "HTML 렌더링 주의",
"no_content_preview": "내용을 입력하면 미리보기가 표시됩니다",
"attachments": "첨부파일",
"attached_files": "첨부된 파일",
"confirm_remove_attachment_title": "첨부파일 삭제",
"confirm_remove_attachment_message": "파일이 즉시 삭제됩니다. 게시글 저장과 관계없이 되돌릴 수 없습니다.",
"attachments_limit": "최대 5개 파일, 각 10MB 이하 (jpg, jpeg, png, gif, pdf, zip)",
"drop_files_or_click": "클릭하여 업로드 또는 드래그 앤 드롭",
"max_10mb": "최대 10MB",
"file_upload_component_needed": "FileUpload 컴포넌트 개발 필요",
"uploading": "업로드 중...",
"reply_to": "다음 글에 대한 답변입니다",
"view_original": "원글 보기",
"show_full_content": "전체 내용 보기",
"hide_content": "내용 숨기기",
"post_options": "게시글 옵션",
"post_content": "게시글 내용",
"parent_post_info": "원글 정보",
"edit_title": "글 수정",
"create_title": "글 작성"
},
"messages": {
"create_success": "게시글이 성공적으로 작성되었습니다",
"update_success": "게시글이 성공적으로 수정되었습니다",
"save_failed": "저장에 실패했습니다"
},
"columns": {
"number": "순번",
"title": "게시글 제목",
"category": "분류",
"author": "작성자",
"view_count": "조회수",
"created_at": "작성일"
},
"search_fields": {
"all": "전체",
"title_content": "제목+내용",
"author": "작성자(이름, 이메일)"
},
"filters": {
"category_label": "분류",
"category_all": "전체 분류",
"category_unclassified": "미분류",
"date_from": "작성일 시작",
"date_to": "작성일 종료",
"status_label": "게시 상태",
"status_all": "전체",
"status_published": "공개",
"status_blinded": "블라인드",
"status_deleted": "삭제",
"status_notice": "공지",
"status_secret": "비밀글"
},
"status": {
"published": "공개",
"blinded": "블라인드",
"notice": "공지",
"secret": "비밀글"
},
"actions": {
"view": "보기",
"edit": "수정",
"blind": "블라인드",
"restore": "복원",
"delete": "삭제",
"create": "게시글 추가"
},
"author_actions": {
"view_user": "사용자 정보 보기"
},
"modals": {
"delete_title": "게시글 삭제",
"delete_confirm": "정말로 이 게시글을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"delete_success": "게시글이 성공적으로 삭제되었습니다.",
"blind_title": "게시글 블라인드",
"blind_comment_title": "댓글 블라인드",
"blind_confirm": "이 게시글을 블라인드 처리하시겠습니까? 블라인드된 게시글은 일반 사용자에게 표시되지 않습니다.",
"blind_comment_confirm": "이 댓글을 블라인드 처리하시겠습니까? 블라인드된 댓글은 일반 사용자에게 표시되지 않습니다.",
"blind_reason_label": "블라인드 사유",
"blind_reason_placeholder": "블라인드 처리 사유를 입력하세요...",
"blind_success": "게시글이 블라인드 처리되었습니다.",
"blind_comment_success": "댓글이 블라인드 처리되었습니다.",
"restore_title": "게시글 복원",
"restore_comment_title": "댓글 복원",
"restore_confirm": "이 게시글을 복원하시겠습니까? 복원된 게시글은 다시 일반 사용자에게 표시됩니다.",
"restore_comment_confirm": "이 댓글을 복원하시겠습니까? 복원된 댓글은 다시 일반 사용자에게 표시됩니다.",
"restore_reason_label": "복원 사유",
"restore_reason_placeholder": "복원 사유를 입력하세요...",
"restore_success": "게시글이 복원되었습니다.",
"restore_comment_success": "댓글이 복원되었습니다.",
"blind_error": "블라인드 처리에 실패했습니다.",
"restore_error": "복원 처리에 실패했습니다."
},
"guest": "비회원",
"notice": "공지",
"secret": "비밀",
"unknown_author": "알 수 없음",
"pagination_info": "전체 {{total}}개 중 {{from}}-{{to}}개 표시",
"sort": {
"latest": "최신순",
"oldest": "오래된순",
"view_count_high": "조회수 높은순",
"view_count_low": "조회수 낮은순"
},
"per_page": {
"15": "15개씩 보기",
"30": "30개씩 보기",
"50": "50개씩 보기",
"100": "100개씩 보기"
}
}
@@ -0,0 +1,243 @@
{
"page_title": "게시판 신고현황",
"page_description": "사용자가 신고한 게시글과 댓글을 확인하고 처리할 수 있습니다.",
"guest_badge": "비회원",
"search_placeholder": "검색어를 입력하세요...",
"search": "검색",
"reset": "초기화",
"advanced_search": "상세검색",
"selected_count": "{{count}}개 선택됨",
"columns": {
"number": "순번",
"type": "유형",
"board_name": "게시판명",
"post_title": "게시글명",
"content": "내용",
"author_name": "작성자",
"reporter_name": "신고자(최초)",
"reported_at": "신고일시",
"last_reported_at": "최근: {{date}}",
"last_reported_at_label": "최근",
"target_status": "대상 상태",
"status": "처리상태",
"current_status": "현재상태"
},
"status_labels": {
"published": "게시중",
"blind": "블라인드",
"deleted": "삭제됨"
},
"statuses": {
"pending": "접수",
"review": "검토",
"rejected": "반려",
"suspended": "게시중단"
},
"filters": {
"status_label": "처리상태",
"status_all": "전체",
"status_pending": "접수",
"status_review": "검토",
"status_rejected": "반려",
"status_suspended": "게시중단",
"status_deleted": "영구 삭제",
"type_label": "신고유형",
"type_all": "전체",
"type_post": "게시글",
"type_comment": "댓글",
"date_from_label": "시작일",
"date_to_label": "종료일",
"target_status_label": "대상 상태",
"target_status_published": "공개",
"target_status_blinded": "블라인드",
"target_status_deleted": "삭제됨",
"reported_at_label": "등록일",
"date_quick_today": "오늘",
"date_quick_3days": "3일",
"date_quick_week": "1주",
"date_quick_month": "1월",
"date_quick_3months": "3월",
"date_quick_all": "전체"
},
"search_fields": {
"all": "전체",
"post_title": "게시글제목",
"board_name": "게시판명",
"author_name": "작성자명",
"reporter_name": "신고자명"
},
"actions": {
"view": "상세 보기",
"delete": "영구 삭제",
"review": "검토",
"reject": "반려",
"suspend": "게시중단",
"go_to_board": "게시판 바로가기",
"go_to_post": "신고글 바로가기"
},
"author_actions": {
"view_info": "작성자 정보",
"search": "작성자 검색",
"view_detail": "사용자 정보"
},
"reporter_actions": {
"view_info": "신고자 정보",
"search": "신고자 검색",
"view_detail": "사용자 정보"
},
"modals": {
"status_change_pending_title": "접수로 변경",
"status_change_pending_confirm": "선택한 {{count}}개 항목을 접수 상태로 변경하시겠습니까?",
"status_change_pending_confirm_single": "이 신고를 접수 상태로 변경하시겠습니까?",
"status_change_review_title": "검토로 변경",
"status_change_review_confirm": "선택한 {{count}}개 항목을 검토 상태로 변경하시겠습니까?",
"status_change_review_confirm_single": "이 신고를 검토 상태로 변경하시겠습니까?",
"status_change_rejected_title": "반려로 변경",
"status_change_rejected_confirm": "선택한 {{count}}개 항목을 반려 상태로 변경하시겠습니까?",
"status_change_rejected_confirm_single": "이 신고를 반려 상태로 변경하시겠습니까?",
"status_change_suspended_title": "게시중단으로 변경",
"status_change_suspended_confirm": "선택한 {{count}}개 항목을 게시중단 상태로 변경하시겠습니까?",
"status_change_suspended_confirm_single": "이 신고를 게시중단 상태로 변경하시겠습니까?",
"status_change_deleted_title": "영구삭제로 변경",
"status_change_deleted_confirm": "선택한 {{count}}개 항목을 영구 삭제합니다. 삭제된 게시물은 복구할 수 없습니다.",
"status_change_deleted_confirm_single": "이 신고를 영구 삭제합니다. 삭제된 게시물은 복구할 수 없습니다.",
"reason_label": "처리 사유 (선택)",
"reason_placeholder": "처리 사유를 입력하세요...",
"processing": "처리 중...",
"loading_status": "상태 정보 로딩 중...",
"selected_status_title": "선택한 신고 상태 (변경 가능)",
"count_unit": "{{count}}건",
"total_count_message": "총 {{count}}건",
"deleted_warning": "영구삭제 {{count}}건은 상태 변경이 불가능합니다.",
"same_status_warning": "이미 {{status}} 상태인 {{count}}건은 변경할 수 없습니다.",
"blinded_will_be_public": "블라인드 처리된 게시물 {{count}}건이 공개로 전환됩니다.",
"changeable_warning_with_deleted": "변경 가능한 {{count}}건을 {{status}} 상태로 변경하시겠습니까?",
"changeable_warning_no_deleted": "선택한 {{count}}건의 신고 {{status}} 상태로 변경하시겠습니까?",
"all_deleted_warning_title": "상태 변경 불가",
"all_deleted_warning_message": "영구삭제된 신고는 상태를 변경할 수 없습니다.",
"all_unchangeable_warning_title": "상태 변경 불가",
"all_unchangeable_warning_message": "선택한 모든 신고가 변경 불가능한 상태입니다. (영구삭제 또는 이미 동일한 상태)",
"permanent_delete_warning": "영구삭제 시 삭제된 게시물은 복구할 수 없습니다.",
"status_change_success": "상태가 성공적으로 변경되었습니다.",
"status_change_success_with_count": "{{count}}개 신고를 {{status}}(으)로 변경했습니다.",
"status_label_pending": "접수",
"status_label_review": "검토",
"status_label_rejected": "반려",
"status_label_suspended": "게시중단",
"status_label_deleted": "영구삭제",
"delete_title": "신고 삭제",
"delete_confirm": "해당 게시글 또는 댓글의 신고를 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며, 모든 관련 데이터가 영구적으로 삭제됩니다.",
"delete_success": "신고가 성공적으로 삭제되었습니다."
},
"pagination_info": "총 {{total}}건 중 {{from}}-{{to}}건 표시",
"bulk_action_select": "일괄 작업 선택",
"status_change_label": "상태 변경",
"detail": {
"page_title": "신고 상세",
"page_description": "신고된 게시글 또는 댓글의 상세 정보를 확인하고 처리할 수 있습니다.",
"report_count_with_unit": "신고 {count}건",
"alert_title_pending": "접수",
"alert_message_pending": "이 신고는 대기 중입니다. 검토를 시작하여 상태를 변경해주세요.",
"alert_title_review": "검토",
"alert_message_review": "이 신고는 현재 검토 중입니다. 추가 검토 후 최종 조치를 결정해주세요.",
"alert_title_rejected": "반려",
"alert_message_rejected": "이 신고는 반려 처리되었습니다. 추가 조치가 필요한 경우 재검토를 수 있습니다.",
"alert_title_suspended": "게시중단",
"alert_message_suspended": "해당 게시글은 중단 처리되었습니다.",
"alert_title_deleted": "영구 삭제",
"alert_message_deleted": "해당 게시글은 영구 삭제되었습니다. 복구할 수 없습니다.",
"alert_title_resolved": "해결",
"alert_message_resolved": "이 신고는 성공적으로 해결되었습니다.",
"actions": {
"start_review": "검토 시작",
"back_to_pending": "검토 취소",
"re_review": "재검토",
"review": "검토처리",
"reject": "신고 반려",
"suspend": "게시 중단",
"delete": "영구 삭제"
},
"post_info_title": "게시글/댓글 정보",
"post_info_title_post": "게시글 정보",
"post_info_title_comment": "댓글 정보",
"report_info_title": "신고 정보",
"board_label": "게시판",
"board_deleted": "삭제됨",
"type_label": "유형",
"title_label": "제목",
"post_title_label": "원글 제목",
"author_label": "작성자",
"content_author_label": "작성자명",
"comment_author_label": "댓글 작성자",
"post_created_at_label": "게시일",
"comment_created_at_label": "댓글 작성일",
"created_at_label": "작성일",
"content_label": "내용",
"reported_content_label": "신고된 내용",
"reports_title": "신고 내역",
"reporter_label": "신고자",
"reported_at_label": "신고일시",
"reason_type_label": "신고 사유",
"reason_detail_label": "상세 사유",
"target_status_label": "대상 상태:",
"report_summary_label": "신고 유형:",
"first_reported_at_label": "신고 일시:",
"name_label": "이름",
"username_label": "아이디",
"view_user_details": "사용자 상세 보기",
"user_info": "사용자 정보",
"histories_title": "처리 내역",
"history_timeline_title": "처리 이력",
"history_timeline": {
"reported": "신고 접수",
"re_reported": "재접수",
"review": "검토",
"rejected": "반려",
"suspended": "게시 중단",
"deleted": "삭제",
"restored": "복구"
},
"no_histories": "처리 내역이 없습니다.",
"cancelled_badge": "취소됨 ({{cancelled_at}})",
"deleted_user": "탈퇴한 사용자",
"table": {
"action": "처리구분",
"processor": "처리자",
"reason": "처리 사유",
"date": "처리 일시",
"reporter": "신고자",
"reported_at": "신고일시",
"cancelled_at": "취소일시",
"reason_type": "신고사유",
"reason_detail": "상세사유"
},
"modals": {
"review_title": "검토처리",
"review_description": "신고 내용을 검토하고 처리 사유를 입력해주세요.",
"reject_title": "신고 반려",
"reject_description": "신고를 반려하는 사유를 입력해주세요.",
"suspend_title": "게시 중단",
"suspend_description": "게시글/댓글을 중단 처리하는 사유를 입력해주세요.",
"reason_label": "사유",
"reason_placeholder": "사유를 입력하세요..."
},
"messages": {
"review_success": "검토처리가 완료되었습니다.",
"reject_success": "신고 반려가 완료되었습니다.",
"suspend_success": "게시 중단 처리가 완료되었습니다."
}
},
"bulk_validation": {
"modal_title": "일괄 처리 결과",
"success_count": "처리 성공",
"failure_count": "처리 실패",
"failed_items_title": "처리 실패 항목",
"column_id": "ID",
"column_target": "대상",
"column_reporter": "신고자",
"column_status": "현재 상태",
"column_reason": "실패 사유",
"no_failed_items": "처리 실패한 항목이 없습니다.",
"process_available_only": "변경 가능한 항목만 처리"
}
}
@@ -0,0 +1,393 @@
{
"title": "게시판 환경설정",
"description": "게시판 모듈의 기본값과 정책을 관리합니다",
"read_only_message": "보기 권한만 있습니다. 수정이 필요한 경우 관리자에게 문의하세요.",
"page_info": "새 게시판 생성 시 적용되는 기본 설정을 관리합니다. 각 항목의 체크박스를 선택하면 기존 게시판에 일괄 적용할 수 있습니다.",
"validation_error": "입력 정보를 확인해주세요.",
"save_success": "설정이 저장되었습니다.",
"save_error": "설정 저장에 실패했습니다.",
"unsaved_changes": "저장되지 않은 변경사항이 있습니다.",
"tabs": {
"general": "기본 설정",
"basic_defaults": "게시판 설정",
"report_policy": "신고 정책",
"spam_security": "보안 설정",
"mail_templates": "메일 템플릿",
"notification_definitions": "알림 설정",
"seo": "SEO 설정"
},
"board_settings_tabs": {
"basic": "기본",
"permissions": "권한",
"list": "목록",
"post": "게시글",
"reply": "답변글",
"comment": "댓글",
"attachment": "첨부파일",
"notification": "알림",
"bulk_apply": "일괄 적용"
},
"sections": {
"general": "기본 설정",
"general_description": "사용자 페이지의 게시판 전체에 즉시 적용되는 설정입니다",
"basic": "기본 설정",
"basic_description": "신규 게시판 생성 시 기본으로 적용되는 설정입니다. 개별 게시판에서 별도로 변경할 수 있으며, '일괄 적용' 기능으로 기존 게시판에도 적용할 수 있습니다.",
"permissions": "권한 설정",
"permissions_description": "게시판 생성 시 적용되는 기본 권한입니다",
"list": "목록 설정",
"list_description": "게시판 목록 페이지 관련 설정입니다",
"post": "게시글 설정",
"post_description": "게시글 작성 관련 설정입니다",
"reply": "답변글 설정",
"reply_description": "답변글 관련 설정입니다",
"comment": "댓글 설정",
"comment_description": "댓글 관련 설정입니다",
"attachment": "첨부파일 설정",
"attachment_description": "파일 업로드 관련 설정입니다",
"notification": "알림 설정",
"notification_description": "알림 관련 설정입니다",
"report": "신고 설정",
"report_description": "신고 기능 관련 설정입니다",
"report_policy": "신고 정책 설정",
"report_policy_description": "신고 자동 처리 및 제한 정책을 설정합니다",
"auto_hide": "자동 숨김 설정",
"auto_hide_description": "신고가 일정 횟수 이상 누적되면 해당 게시글\/댓글을 자동으로 블라인드 처리합니다.",
"report_abuse_prevention": "신고 제한 설정",
"report_abuse_prevention_description": "사용자당 하루 신고 횟수를 제한하고, 반려가 누적되면 일정 기간 신고 기능을 차단합니다. 모든 게시판에 동일하게 적용됩니다.",
"spam_security": "보안 설정",
"spam_security_description": "쿨다운, 조회수 관련 보안 설정입니다",
"report_notification": "신고 알림",
"report_notification_description": "신고 접수 시 알림을 받을 관리자와 알림 발송 방식을 설정합니다.",
"report_permissions": "신고 관리 권한",
"report_permissions_description": "신고 목록 조회 및 신고 처리(승인·반려·블라인드)를 수행할 수 있는 역할을 지정합니다. 신고 접수 알림은 '신고 처리 권한'을 가진 역할 기준으로 발송됩니다."
},
"fields": {
"type": "게시판 유형",
"secret_mode": "비밀글 모드",
"per_page": "페이지당 게시글 수 (PC)",
"per_page_mobile": "페이지당 게시글 수 (Mobile)",
"order_by": "기본 정렬 기준",
"order_direction": "기본 정렬 방향",
"new_display_hours": "신규 게시글 표시 기간",
"new_display_hours_hint": "게시글 작성 후 [New] 배지를 표시할 시간 (1~720)",
"show_view_count": "조회수 표시",
"min_title_length": "최소 제목 글자 수",
"max_title_length": "최대 제목 글자 수",
"min_content_length": "최소 게시글 글자 수",
"max_content_length": "최대 게시글 글자 수",
"use_reply": "답변글 사용",
"max_reply_depth": "답변글 최대 깊이",
"max_reply_depth_hint": "1~5 사이의 값을 입력하세요",
"use_comment": "댓글 사용",
"comment_order": "댓글 정렬 순서",
"min_comment_length": "최소 댓글 글자 수",
"max_comment_length": "최대 댓글 글자 수",
"max_comment_depth": "대댓글 최대 깊이",
"max_comment_depth_hint": "0~10 사이의 값을 입력하세요. 0으로 설정하면 대댓글 기능이 비활성화됩니다.",
"use_file_upload": "첨부 파일 설정",
"max_file_size": "최대 파일 용량 (MB)",
"max_file_size_hint": "MB 단위로 입력",
"max_file_count": "최대 파일 개수",
"allowed_extensions": "허용 확장자",
"allowed_extensions_placeholder": "확장자 입력 후 Enter (예: jpg, png, pdf)",
"notify_admin_on_post": "관리자 알림",
"notify_admin_on_post_description": "게시글 등록 시 관리자에게 이메일 발송",
"notify_author": "작성자 알림",
"notify_author_description": "댓글, 답변글 작성 시 원글 작성자에게 이메일 발송",
"use_report": "신고 사용",
"auto_hide_threshold": "자동 숨김 기준 (회)",
"auto_hide_threshold_hint": "이 횟수 이상 신고가 접수되면 자동으로 블라인드 처리됩니다. (0=비활성, 1~100)",
"auto_hide_target": "적용 대상",
"auto_hide_target_hint": "블라인드 처리를 적용할 대상을 선택합니다.",
"daily_report_limit": "일일 신고 횟수 제한",
"daily_report_limit_hint": "하루(자정 기준) 전체 게시판 통틀어 신고할 수 있는 최대 횟수. 0이면 제한 없음. (0~100)",
"rejection_limit_count": "반려 누적 제한 (건수)",
"rejection_limit_days": "반려 누적 제한 기간 (일)",
"rejection_limit_hint": "이 기간 내 반려 건수 초과 시 신고 기능이 차단됩니다. 건수 0이면 제한 없음. (건수: 0~50, 기간: 1~365일)",
"report_cooldown_seconds": "연속 신고 쿨타임 (초)",
"report_cooldown_seconds_hint": "연속 신고 최소 간격. 0이면 제한 없음. (0~3600초)",
"notify_author_on_report_action": "신고 처리 알림",
"notify_author_on_report_action_description": "신고로 인해 처리(블라인드\/삭제\/복구)될 때 작성자에게 알림을 발송합니다",
"notify_admin_on_report": "신고 접수 관리자 알림",
"notify_admin_on_report_description": "신고 접수 시 '신고 처리 권한'을 가진 역할의 관리자에게 알림을 발송합니다. 아래 발송 범위에 따라 알림 횟수가 달라집니다.",
"notify_admin_on_report_scope": "알림 발송 범위",
"notify_admin_on_report_scope_per_case": "게시글\/댓글당 최초 1회만",
"notify_admin_on_report_scope_per_report": "신고가 접수될 때마다",
"report_view_roles": "신고 조회 권한",
"report_view_roles_description": "신고 목록과 상세 내용을 열람할 수 있는 권한입니다.",
"report_manage_roles": "신고 처리 권한",
"report_manage_roles_description": "신고를 승인·반려·블라인드 처리할 수 있는 권한입니다. 신고 접수 시 관리자 알림도 이 권한을 가진 역할 기준으로 발송됩니다.",
"blocked_keywords": "제한 키워드",
"blocked_keywords_placeholder": "키워드 입력 후 Enter",
"blocked_keywords_description": "게시글\/댓글에서 차단할 키워드 목록입니다",
"blocked_keywords_hint": "등록된 키워드가 포함된 게시글\/댓글은 작성이 차단됩니다",
"post_cooldown_seconds": "게시글 쿨다운 (초)",
"post_cooldown_seconds_hint": "같은 사용자가 연속 게시글 작성 시 대기 시간 (0~3600초, 0 = 제한 없음)",
"comment_cooldown_seconds": "댓글 쿨다운 (초)",
"comment_cooldown_seconds_hint": "같은 사용자가 연속 댓글 작성 시 대기 시간 (0~3600초, 0 = 제한 없음)",
"spam_security_identifier_info": "쿨다운 및 조회수 중복 방지는 회원은 user_id, 비회원은 IP 주소로 식별합니다",
"view_count_cache_ttl": "조회수 캐시 TTL (초)",
"view_count_cache_ttl_hint": "조회수 중복 방지 캐시 유효 기간 (60~604800초, 최대 7일)",
"date_display_format": "날짜 표시 방식",
"date_display_format_hint": "게시글·댓글 목록에 날짜를 표시하는 방식을 선택합니다. 사용자 페이지의 게시판 전체에 즉시 적용됩니다.",
"clear_cache": "캐시 초기화",
"clear_cache_description": "게시판 관련 캐시를 모두 초기화합니다",
"clear_cache_success": "캐시가 초기화되었습니다.",
"clear_cache_error": "캐시 초기화에 실패했습니다.",
"descriptions": {
"type": "새 게시판의 기본 표시 형식을 설정합니다",
"secret_mode": "비밀글 기능의 기본 동작을 설정합니다",
"show_view_count": "게시글 목록 및 상세에서 조회수를 표시합니다",
"use_report": "부적절한 게시글\/댓글 신고 기능을 활성화합니다",
"new_display_hours": "게시글 작성 후 [New] 배지를 표시할 기간 (시간 단위)",
"per_page": "PC 환경에서 한 페이지에 표시할 게시글 수입니다",
"per_page_mobile": "모바일 환경에서 한 페이지에 표시할 게시글 수입니다",
"order_by": "게시글 목록의 기본 정렬 기준을 설정합니다",
"order_direction": "게시글 목록의 기본 정렬 방향을 설정합니다",
"min_title_length": "게시글 제목의 최소 글자 수를 설정합니다",
"max_title_length": "게시글 제목의 최대 글자 수를 설정합니다",
"min_content_length": "게시글 본문의 최소 글자 수를 설정합니다",
"max_content_length": "게시글 본문의 최대 글자 수를 설정합니다",
"use_reply": "ON으로 설정해야 새 게시판에 답변글 기능이 적용됩니다.",
"max_reply_depth": "답변글의 최대 중첩 깊이를 설정합니다 (1~5)",
"blocked_keywords": "등록된 키워드가 포함된 게시글\/댓글은 작성이 차단됩니다",
"use_comment": "ON으로 설정해야 새 게시판에 댓글 기능이 적용됩니다. 아래 댓글 세부 설정도 함께 적용됩니다.",
"comment_order": "댓글의 기본 정렬 방향을 설정합니다",
"min_comment_length": "댓글의 최소 글자 수를 설정합니다",
"max_comment_length": "댓글의 최대 글자 수를 설정합니다",
"max_comment_depth": "대댓글의 최대 중첩 깊이를 설정합니다. 0으로 설정하면 대댓글 기능이 비활성화됩니다 (0~10)",
"use_file_upload": "ON으로 설정해야 새 게시판에 파일 첨부 기능이 적용됩니다.",
"max_file_size": "업로드 가능한 최대 파일 크기입니다 (MB 단위)",
"max_file_count": "게시글당 첨부 가능한 최대 파일 수입니다",
"allowed_extensions": "빈 값일 경우 모든 확장자가 허용됩니다"
}
},
"options": {
"date_display_format": {
"standard": "표준형",
"standard_description": "최근 글은 'N분 전 \/ N시간 전'으로, 올해 글은 'MM-DD', 오래된 글은 'YY-MM-DD'로 표시",
"standard_example": "예) 3분 전 · 2시간 전 · 03-18 · 24-12-25",
"relative": "유동형",
"relative_description": "모든 날짜를 상대 시간으로 표시 (N분·N시간·N일·N개월·N년 전)",
"relative_example": "예) 3분 전 · 2시간 전 · 5일 전 · 3개월 전 · 2년 전"
},
"type": {
"basic": "기본형",
"gallery": "갤러리형",
"card": "카드형"
},
"secret_mode": {
"disabled": "사용 안함",
"enabled": "선택 가능",
"always": "항상 비밀글"
},
"order_by": {
"created_at": "최신순",
"view_count": "조회수순",
"title": "제목순",
"author": "작성자순"
},
"order_direction": {
"DESC": "내림차순",
"ASC": "오름차순"
},
"comment_order": {
"ASC": "오름차순 (오래된순)",
"DESC": "내림차순 (최신순)"
},
"auto_hide_target": {
"post": "게시글만",
"comment": "댓글만",
"both": "게시글 + 댓글"
}
},
"permissions_table": {
"title": "기본 권한 설정",
"description": "각 권한에 대해 접근 가능한 역할을 설정합니다.",
"bulk_apply_notice": "일괄 적용 시 선택한 권한은 게시판의 기존 역할을 현재 설정값으로 교체합니다. 단, 해당 게시판의 관리자(manager)·스텝(step) 역할은 설정값 그대로 유지됩니다.",
"admin_section": "관리자 권한",
"user_section": "일반 권한",
"role_admin": "관리자",
"role_user": "회원",
"role_guest": "비회원",
"header_permission": "권한",
"header_admin": "관리자",
"header_user": "사용자",
"header_guest": "비회원",
"select_permissions": "권한을 선택하세요",
"select_roles": "역할을 선택하세요",
"permissions": {
"admin.posts.read": "게시글 조회",
"admin.posts.write": "게시글 작성\/수정\/삭제",
"admin.posts.read-secret": "비밀글 조회",
"admin.comments.read": "댓글 조회",
"admin.comments.write": "댓글 작성\/수정\/삭제",
"admin.attachments.upload": "파일 업로드\/삭제",
"admin.attachments.download": "파일 다운로드",
"admin.manage": "게시판 관리",
"posts.read": "게시글 조회",
"posts.write": "게시글 작성\/수정\/삭제",
"posts.read-secret": "비밀글 조회",
"comments.read": "댓글 조회",
"comments.write": "댓글 작성\/수정\/삭제",
"attachments.upload": "파일 업로드\/삭제",
"attachments.download": "파일 다운로드",
"manager": "게시판 관리"
}
},
"notification_channels": {
"channel_mail": "이메일",
"channel_database": "사이트 알림",
"channels_label": "알림 채널"
},
"bulk_apply": {
"title": "기존 게시판에 일괄 적용",
"description": "위 설정을 기존 게시판에 일괄 적용합니다. 적용할 항목과 대상 게시판을 선택하세요.",
"select_fields": "적용할 항목 선택",
"select_all": "전체 선택",
"target_boards": "대상 게시판",
"apply_all": "전체 게시판",
"apply_selected": "선택한 게시판",
"select_boards_placeholder": "게시판을 선택하세요",
"apply_button": "일괄 적용",
"no_fields_selected": "적용할 항목이 선택되지 않았습니다.",
"no_boards_selected": "적용할 게시판이 선택되지 않았습니다.",
"checked_fields_notice": "전체 설정 저장 후 체크한 항목만 일괄 적용됩니다.",
"no_boards_notice": "등록된 게시판이 없어 일괄 적용할 수 없습니다. 먼저 게시판을 생성해주세요.",
"field_labels": {
"type": "기본 유형",
"per_page": "페이지당 게시글 수 (PC)",
"per_page_mobile": "페이지당 게시글 수 (Mobile)",
"order_by": "기본 정렬 기준",
"order_direction": "기본 정렬 방향",
"secret_mode": "비밀글 모드",
"use_comment": "댓글 사용",
"use_reply": "답변글 사용",
"max_reply_depth": "답변글 최대 깊이",
"max_comment_depth": "대댓글 최대 깊이",
"comment_order": "댓글 정렬 순서",
"show_view_count": "조회수 표시",
"use_report": "신고 사용",
"min_title_length": "최소 제목 글자 수",
"max_title_length": "최대 제목 글자 수",
"min_content_length": "최소 내용 글자 수",
"max_content_length": "최대 내용 글자 수",
"min_comment_length": "최소 댓글 글자 수",
"max_comment_length": "최대 댓글 글자 수",
"use_file_upload": "파일 업로드 사용",
"max_file_size": "최대 파일 용량",
"max_file_count": "최대 파일 개수",
"allowed_extensions": "허용 확장자",
"notify_admin_on_post": "관리자 알림",
"notify_author": "작성자 알림",
"new_display_hours": "새글 표시 시간",
"default_board_permissions": "기본 권한",
"manager": "게시판 관리"
}
},
"bulk_apply_modal": {
"title": "일괄 적용 확인",
"message": "전체 설정 저장 후 체크한 항목을 일괄 적용합니다. 이 작업은 되돌릴 수 없습니다.",
"message_all": "전체 설정 저장 후 체크한 항목을 일괄 적용합니다. 이 작업은 되돌릴 수 없습니다.",
"all_boards": "전체 게시판",
"warning": "이 작업은 되돌릴 수 없습니다. 적용 대상 게시판의 기존 설정이 덮어씌워집니다.",
"confirm": "저장 및 적용",
"success": "{{count}}개 게시판에 설정이 적용되었습니다.",
"error": "일괄 적용에 실패했습니다.",
"selected_fields": "적용할 항목",
"selected_boards": "적용할 게시판",
"no_fields": "선택된 항목이 없습니다.",
"no_boards": "선택된 게시판이 없습니다.",
"processing": "적용 중...",
"select_boards_required": "게시판을 선택해 주세요.",
"save_and_apply_info": "확인 시 현재 탭의 설정 전체가 저장되며, 체크된 항목만 선택한 게시판에 일괄 적용됩니다.",
"save_error": "설정 저장에 실패했습니다. 일괄 적용을 중단합니다.",
"permission_notice": "선택한 권한 항목은 게시판의 기존 역할을 현재 설정값으로 교체합니다. 단, 해당 게시판의 관리자(manager)·스텝(step) 역할은 설정값 그대로 유지됩니다."
},
"seo": {
"meta_settings": {
"title": "메타 설정",
"description": "검색엔진에 표시될 제목과 설명을 페이지 유형별로 설정합니다.",
"meta_title": "메타 제목",
"meta_description": "메타 설명",
"available_variables": "사용 가능한 변수"
},
"boards_page": {
"title": "게시판 목록 페이지",
"title_placeholder": "{site_name}의 게시판"
},
"board_page": {
"title": "게시판 글 목록 페이지",
"title_placeholder": "{board_name}"
},
"post_page": {
"title": "게시글 상세 페이지",
"title_placeholder": "{board_name} - {post_title}"
},
"seo_friendly": {
"title": "SEO 제공 페이지",
"description": "검색 봇에게 HTML을 제공할 페이지를 선택합니다.",
"provide_pages": "SEO를 제공할 페이지",
"boards": "게시판 목록 페이지",
"board": "게시판 글 목록 페이지",
"post_detail": "게시글 상세 페이지",
"cache_management": "SEO 캐시 관리",
"cache_description": "게시판 관련 SEO 캐시를 수동으로 초기화합니다. 설정 저장 시 자동으로 초기화됩니다.",
"clear_cache": "SEO 캐시 초기화",
"clear_cache_success": "SEO 캐시가 초기화되었습니다.",
"clear_cache_error": "SEO 캐시 초기화에 실패했습니다."
}
},
"notification_definitions": {
"title": "알림 설정",
"description": "채널별 게시판 알림 템플릿을 관리합니다.",
"channel_management": "알림 채널 관리",
"active": "활성",
"inactive": "비활성",
"default_badge": "기본",
"btn_edit": "편집",
"btn_reset": "기본값 복원",
"toggle_success": "알림 활성 상태가 변경되었습니다.",
"reset_success": "알림 설정이 기본값으로 복원되었습니다.",
"reset_failed": "알림 설정 복원에 실패했습니다.",
"reset_modal": {
"title": "기본값으로 복원",
"confirm_message": "이 알림의 모든 채널 템플릿을 기본값으로 복원합니다.",
"warning_message": "저장된 제목/본문/수신자 설정이 모두 사라지며 복구할 수 없습니다.",
"target_label": "복원 대상:",
"btn_confirm": "복원",
"btn_confirming": "복원 중..."
},
"no_template_for_channel": "이 채널에 대한 템플릿이 없습니다.",
"body_preview": "본문 미리보기",
"total_count": "총 {{count}}건",
"per_page_label": "페이지당",
"empty_message": "등록된 알림 설정이 없습니다.",
"types": {
"new_comment": "새 댓글 알림",
"reply_comment": "대댓글 알림",
"post_reply": "답변글 알림",
"post_action": "관리자 처리 알림",
"new_post_admin": "신규 게시글 (관리자)",
"report_received_admin": "신고 접수 (관리자)",
"report_action": "신고 처리 결과"
},
"save_success": "알림 템플릿이 저장되었습니다.",
"save_error": "알림 템플릿 저장에 실패했습니다.",
"edit_modal": {
"title": "알림 템플릿 편집",
"type_label": "유형:",
"channel_label": "채널:",
"subject": "제목",
"body": "본문",
"variables_title": "사용 가능한 변수",
"btn_preview": "미리보기",
"validation_error": "입력 정보를 확인해주세요."
},
"preview_modal": {
"title": "알림 미리보기",
"subject_label": "제목",
"body_label": "본문"
}
}
}
@@ -0,0 +1,13 @@
{
"form": {
"sections": {
"notification_settings": "알림 설정"
},
"notifications": {
"post_complete": "게시글 완료 알림",
"post_reply": "게시글 답변 알림",
"comment": "댓글 알림",
"reply_comment": "답변 알림"
}
}
}
@@ -94,14 +94,6 @@
"min": "대댓글 최대 깊이는 최소 :min 이상이어야 합니다.",
"max": "대댓글 최대 깊이는 최대 :max까지 설정 가능합니다."
},
"notify_author_channels": {
"array": "작성자 알림 채널은 배열이어야 합니다.",
"item_max": "알림 채널명은 최대 :max자까지 입력 가능합니다."
},
"notify_admin_on_post_channels": {
"array": "관리자 알림 채널은 배열이어야 합니다.",
"item_max": "알림 채널명은 최대 :max자까지 입력 가능합니다."
},
"settings": {
"category": {
"required": "카테고리는 필수입니다.",
@@ -9,6 +9,9 @@
"title": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}",
"description": "$t:sirsoft-board.admin.form.description"
},
"computed": {
"isReadOnly": "{{!!route?.id && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
},
"state": {
"activeTab": "basic",
"showCategoryRemoveModal": false,
@@ -21,6 +24,12 @@
"managerSearchResults": [],
"stepSearchResults": []
},
"transition_overlay": {
"wait_for": [
"roles",
"notification_channels"
]
},
"data_sources": [
{
"id": "boards",
@@ -61,17 +70,17 @@
"auth_required": true
},
{
"id": "notification_channels",
"id": "availableChannels",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/notification-channels",
"endpoint": "/api/admin/notification-channels",
"method": "GET",
"auto_fetch": true,
"auth_required": true
"auth_required": true,
"fallback": {
"data": { "channels": [] }
}
}
],
"computed": {
"isReadOnly": "{{!!route?.id && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
},
"slots": {
"content": [
{
@@ -9,6 +9,11 @@
"title": "$t:sirsoft-board.admin.board.index.title",
"description": "$t:sirsoft-board.admin.board.index.description"
},
"transition_overlay": {
"wait_for": [
"boards"
]
},
"data_sources": [
{
"id": "boards",
@@ -297,10 +302,30 @@
"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}}" }
{
"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": [
@@ -9,6 +9,14 @@
"title": "{{post?.data?.board?.name}} $t:sirsoft-board.admin.posts.detail.title",
"description": "{{post?.data?.board?.name}}$t:sirsoft-board.admin.posts.page_description_with_board"
},
"computed": {
"commentRootMap": "{{(post?.data?.comments ?? []).reduce((map, c) => ({...map, [c.id]: c.depth === 0 ? c.id : (map[c.parent_id] ?? c.parent_id)}), {})}}"
},
"transition_overlay": {
"wait_for": [
"post"
]
},
"data_sources": [
{
"id": "post",
@@ -44,9 +52,6 @@
}
}
],
"computed": {
"commentRootMap": "{{(post?.data?.comments ?? []).reduce((map, c) => ({...map, [c.id]: c.depth === 0 ? c.id : (map[c.parent_id] ?? c.parent_id)}), {})}}"
},
"slots": {
"content": [
{
@@ -9,6 +9,9 @@
"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"
},
"computed": {
"isReadOnly": "{{!!route?.id && !!form_meta?.data && form_meta?.data?.board?.user_abilities?.can_write !== true}}"
},
"state": {
"isSaving": false,
"hasChanges": false,
@@ -18,6 +21,11 @@
"errors": null,
"uploadingFiles": []
},
"transition_overlay": {
"wait_for": [
"form_meta"
]
},
"data_sources": [
{
"id": "form_data",
@@ -77,9 +85,6 @@
}
}
],
"computed": {
"isReadOnly": "{{!!route?.id && !!form_data?.data && form_data?.data?.abilities?.can_write !== true}}"
},
"init_actions": [
{
"comment": "tempKey 생성 (신규/수정 모두 - 취소 시 안전을 위해)",
@@ -13,6 +13,11 @@
"searchField": "all",
"searchQuery": ""
},
"transition_overlay": {
"wait_for": [
"posts"
]
},
"init_actions": [
{
"handler": "setState",
@@ -200,8 +205,8 @@
"value": "all"
},
{
"label": "$t:sirsoft-board.admin.posts.search_fields.title",
"value": "title"
"label": "$t:sirsoft-board.admin.posts.search_fields.title_content",
"value": "title_content"
},
{
"label": "$t:sirsoft-board.admin.posts.search_fields.author",
@@ -333,7 +338,8 @@
"query": {
"category": "{{$event.target.value === 'all' ? '' : $event.target.value}}",
"page": 1
}
},
"transition_overlay_target": "posts_data_grid__body"
}
}
]
@@ -626,7 +632,8 @@
"sort_by": "{{$event.target.value.split('_').slice(0, -1).join('_')}}",
"sort_order": "{{$event.target.value.split('_').pop()}}",
"page": 1
}
},
"transition_overlay_target": "posts_data_grid__body"
}
}
]
@@ -667,7 +674,8 @@
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
}
},
"transition_overlay_target": "posts_data_grid__body"
}
}
]
@@ -729,7 +737,7 @@
"props": {
"className": "flex items-start gap-2",
"style": {
"paddingLeft": "{{(row?.depth ?? 0) > 0 ? (row?.depth * 1) + 'rem' : '0'}}"
"paddingLeft": "{{(row?.depth ?? 0) > 0 ? (row?.depth * 1) + 'rem' : '0'}}"
}
},
"children": [
@@ -969,7 +977,8 @@
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
}
},
"transition_overlay_target": "posts_data_grid__body"
}
}
]
@@ -20,6 +20,12 @@
"actionType": ""
}
},
"transition_overlay": {
"wait_for": [
"report_detail",
"reporters_list"
]
},
"data_sources": [
{
"id": "report_detail",
@@ -13,6 +13,11 @@
"searchField": "all",
"searchQuery": ""
},
"transition_overlay": {
"wait_for": [
"reports"
]
},
"init_actions": [
{
"handler": "setState",
@@ -998,58 +1003,58 @@
"className": "flex flex-wrap items-center gap-2"
},
"children": [
{
"id": "date_from",
"type": "basic",
"name": "Input",
"props": {
"type": "date",
"value": "{{_global.dateFrom !== undefined ? _global.dateFrom : (query.reported_at_from || '')}}",
"className": "px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white text-sm"
},
"actions": [
{
"id": "date_from",
"type": "basic",
"name": "Input",
"props": {
"type": "date",
"value": "{{_global.dateFrom !== undefined ? _global.dateFrom : (query.reported_at_from || '')}}",
"className": "px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white text-sm"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"dateFrom": "{{$event.target.value}}",
"dateQuick": ""
}
}
]
},
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"dateFrom": "{{$event.target.value}}",
"dateQuick": ""
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "~"
},
{
"id": "date_to",
"type": "basic",
"name": "Input",
"props": {
"type": "date",
"value": "{{_global.dateTo !== undefined ? _global.dateTo : (query.reported_at_to || '')}}",
"className": "px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white text-sm"
},
"actions": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "~"
},
{
"id": "date_to",
"type": "basic",
"name": "Input",
"props": {
"type": "date",
"value": "{{_global.dateTo !== undefined ? _global.dateTo : (query.reported_at_to || '')}}",
"className": "px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-white text-sm"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"dateTo": "{{$event.target.value}}",
"dateQuick": ""
}
}
]
},
{
"id": "date_quick_buttons",
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"dateTo": "{{$event.target.value}}",
"dateQuick": ""
}
}
]
},
{
"id": "date_quick_buttons",
"type": "basic",
"name": "Div",
"props": {
@@ -1620,7 +1625,7 @@
"actions": [
{
"event": "onItemClick",
"handler": "switch",
"handler": "switch",
"cases": {
"review": {
"handler": "sequence",
@@ -2174,14 +2179,15 @@
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
}
},
"transition_overlay_target": "reports_data_grid__body"
}
}
]
},
{
"event": "onRowAction",
"handler": "switch",
"handler": "switch",
"value": "{{$args[0]}}",
"cases": {
"view": {
@@ -9,22 +9,51 @@
"title": "$t:sirsoft-board.admin.settings.title",
"description": "$t:sirsoft-board.admin.settings.description"
},
"computed": {
"isReadOnly": "{{!!settings?.data && settings?.data?.abilities?.can_update !== true}}",
"isNotificationReadOnly": "{{boardNotificationDefinitions?.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))}}"
},
"state": {
"mailTemplateCurrentPage": 1,
"mailTemplateTotalPages": 1,
"mailTemplateTotalItems": 0
"notificationDefinitionCurrentPage": 1,
"notificationDefinitionTotalPages": 1,
"notificationDefinitionTotalItems": 0
},
"transition_overlay": {
"wait_for": [
"settings",
"boards_list",
"roles",
"board_types",
"boardNotificationDefinitions",
"notification_channels"
]
},
"initLocal": {
"activeNotificationChannel": null,
"editingDefinition": null,
"editingTemplate": null,
"editingRecipients": [],
"editLang": "ko",
"previewData": null,
"mailTemplateFilter": {
"notificationDefinitionFilter": {
"search": "",
"searchType": "all"
},
"expandedMailTemplateIds": []
"expandedNotificationDefinitionIds": [],
"expandedNotifications": {},
"templateErrors": null,
"isSaving": false,
"recipientRoleSearchResults": [],
"recipientUserSearchResults": []
},
"data_sources": [
{
"id": "settings",
@@ -80,9 +109,9 @@
}
},
{
"id": "boardMailTemplates",
"id": "boardNotificationDefinitions",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/mail-templates",
"endpoint": "/api/admin/notification-definitions?extension_type=module&extension_identifier=sirsoft-board",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
@@ -94,24 +123,27 @@
},
"initLocal": {
"_merge": "deep",
"mailTemplateCurrentPage": "pagination.current_page",
"mailTemplateTotalPages": "pagination.last_page",
"mailTemplateTotalItems": "pagination.total"
"notificationDefinitionCurrentPage": "pagination.current_page",
"notificationDefinitionTotalPages": "pagination.last_page",
"notificationDefinitionTotalItems": "pagination.total"
},
"fallback": {
"data": {
"data": [],
"pagination": {}
"pagination": []
}
}
},
{
"id": "notification_channels",
"id": "availableChannels",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/notification-channels",
"endpoint": "/api/admin/notification-channels",
"method": "GET",
"auto_fetch": true,
"auth_required": true
"auth_required": true,
"fallback": {
"data": { "channels": [] }
}
}
],
"init_actions": [
@@ -119,21 +151,10 @@
"handler": "setState",
"params": {
"target": "_local",
"mailTemplateCurrentPage": "{{+(query.page || 1)}}"
"notificationDefinitionCurrentPage": "{{+(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": [
{
@@ -184,7 +205,6 @@
"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"
},
@@ -250,83 +270,85 @@
{
"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 || {} }; })()}}"
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true,
"errors": null
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false
}
{
"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 || {} }; } if (tab === 'seo') { return { _tab: 'seo', seo: form.seo || {} }; } if (tab === 'notification_definitions') { return { _tab: 'notifications', notifications: { channels: form.notifications?.channels || [] } }; } return { _tab: 'basic_defaults', basic_defaults: form.basic_defaults || {}, notifications: form.notifications || {} }; })()}}"
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "settings"
"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"
}
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boards_list"
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.save_error"
}
}
},
{
"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": [
@@ -409,8 +431,12 @@
"label": "$t:sirsoft-board.admin.settings.tabs.spam_security"
},
{
"id": "mail_templates",
"label": "$t:sirsoft-board.admin.settings.tabs.mail_templates"
"id": "seo",
"label": "$t:sirsoft-board.admin.settings.tabs.seo"
},
{
"id": "notification_definitions",
"label": "$t:sirsoft-board.admin.settings.tabs.notification_definitions"
}
],
"activeTabId": "{{_global.activeBoardSettingsTab || query.tab || 'general'}}",
@@ -421,30 +447,32 @@
"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]}}"
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"errors": null
}
},
{
"handler": "setState",
"params": {
"target": "global",
"activeBoardSettingsTab": "{{$args[0]}}"
}
},
{
"handler": "replaceUrl",
"params": {
"query": {
"tab": "{{$args[0]}}"
}
}
}
}
]
]
}
}
]
}
@@ -623,7 +651,10 @@
"partial": "partials/admin_board_settings/_tab_spam_security.json"
},
{
"partial": "partials/admin_board_settings/_tab_mail_templates.json"
"partial": "partials/admin_board_settings/_tab_seo.json"
},
{
"partial": "partials/admin_board_settings/_tab_notification_definitions.json"
}
]
}
@@ -639,10 +670,13 @@
"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_notification_template_edit.json"
},
{
"partial": "partials/admin_board_settings/_modal_mail_template_preview.json"
"partial": "partials/admin_board_settings/_modal_notification_template_preview.json"
},
{
"partial": "partials/admin_board_settings/_modal_notification_definition_reset.json"
}
]
}
@@ -98,33 +98,6 @@
}
}
]
},
{
"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"
}
}
]
}
]
},
@@ -176,33 +149,6 @@
}
}
]
},
{
"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"
}
}
]
}
]
}
@@ -696,13 +696,24 @@
{
"comment": "일반 글인 경우 - 내용",
"if": "{{!(post?.data?.parent?.is_secret && post?.data?.parent?.content === null)}}",
"type": "composite",
"name": "HtmlContent",
"type": "extension_point",
"name": "html_content",
"props": {
"content": "{{post?.data?.parent?.content ?? ''}}",
"isHtml": "{{(post?.data?.parent?.content_mode ?? 'text') === 'html'}}",
"className": "{{_local?.parentPostExpanded ? '' : '[&_img]:hidden [&_table]:hidden'}}"
}
"className": "{{_local?.parentPostExpanded ? 'prose dark:prose-invert max-w-none' : '[&_img]:hidden [&_table]:hidden'}}"
},
"default": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{post?.data?.parent?.content ?? ''}}",
"isHtml": "{{(post?.data?.parent?.content_mode ?? 'text') === 'html'}}",
"className": "{{_local?.parentPostExpanded ? 'prose dark:prose-invert max-w-none' : '[&_img]:hidden [&_table]:hidden'}}"
}
}
]
}
]
},
@@ -1063,13 +1074,24 @@
]
},
{
"type": "composite",
"name": "HtmlContent",
"type": "extension_point",
"name": "html_content",
"props": {
"content": "{{post?.data?.content}}",
"isHtml": "{{(post?.data?.content_mode ?? 'text') === 'html'}}",
"className": "text-gray-600 dark:text-gray-400"
}
"className": "prose dark:prose-invert max-w-none text-gray-600 dark:text-gray-400"
},
"default": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{post?.data?.content}}",
"isHtml": "{{(post?.data?.content_mode ?? 'text') === 'html'}}",
"className": "prose dark:prose-invert max-w-none text-gray-600 dark:text-gray-400"
}
}
]
}
]
}
@@ -1077,14 +1099,25 @@
},
{
"id": "normal_content",
"type": "composite",
"name": "HtmlContent",
"type": "extension_point",
"name": "html_content",
"if": "{{post?.data?.status !== 'blinded'}}",
"props": {
"content": "{{post?.data?.content}}",
"isHtml": "{{(post?.data?.content_mode ?? 'text') === 'html'}}",
"className": "text-gray-900 dark:text-gray-100"
}
"className": "prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100"
},
"default": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{post?.data?.content}}",
"isHtml": "{{(post?.data?.content_mode ?? 'text') === 'html'}}",
"className": "prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100"
}
}
]
}
]
},
@@ -411,13 +411,25 @@
},
"children": [
{
"type": "composite",
"name": "HtmlContent",
"id": "reply_html_content",
"type": "extension_point",
"name": "html_content",
"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"
}
"className": "{{_local.expandedReplies?.[item.id] ? 'prose dark:prose-invert max-w-none' : 'line-clamp-3'}} text-gray-900 dark:text-gray-100"
},
"default": [
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{item?.content}}",
"isHtml": "{{(item?.content_mode ?? 'text') === 'html'}}",
"className": "{{_local.expandedReplies?.[item.id] ? 'prose dark:prose-invert max-w-none' : 'line-clamp-3'}} text-gray-900 dark:text-gray-100"
}
}
]
},
{
"type": "basic",
@@ -371,42 +371,55 @@
},
{
"id": "content_section",
"type": "composite",
"name": "HtmlEditor",
"type": "extension_point",
"name": "html_editor",
"props": {
"content": "{{_local.form?.content ?? ''}}",
"isHtml": "{{(_local.form?.content_mode ?? 'text') === 'html'}}",
"disabled": "{{_computed.isReadOnly}}",
"rows": 15,
"name": "content",
"placeholder": "$t:sirsoft-board.admin.posts.form.content_placeholder",
"label": "$t:sirsoft-board.admin.posts.form.content",
"showHtmlModeToggle": true,
"name": "content"
"rows": 15,
"showHtmlModeToggle": true
},
"actions": [
"default": [
{
"event": "onChange",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": {
"content": "{{$args[0].target.value}}"
"type": "composite",
"name": "HtmlEditor",
"props": {
"name": "content",
"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
},
"actions": [
{
"event": "onChange",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": {
"content": "{{$args[0].target.value}}"
},
"hasChanges": true
}
},
"hasChanges": true
}
},
{
"event": "onIsHtmlChange",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": {
"content_mode": "{{$args[0].target.checked ? 'html' : 'text'}}"
},
"hasChanges": true
}
{
"event": "onIsHtmlChange",
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form": {
"content_mode": "{{$args[0].target.checked ? 'html' : 'text'}}"
},
"hasChanges": true
}
}
]
}
]
}
@@ -123,13 +123,24 @@
},
"children": [
{
"type": "composite",
"name": "HtmlContent",
"type": "extension_point",
"name": "html_content",
"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"
}
},
"default": [
{
"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",
@@ -543,13 +543,25 @@
},
"children": [
{
"type": "composite",
"name": "HtmlContent",
"id": "report_target_html",
"type": "extension_point",
"name": "html_content",
"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"
}
},
"default": [
{
"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"
}
}
]
}
]
}
@@ -363,7 +363,7 @@
"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 || {} }; })()}}"
"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 || {}, notifications: form.notifications || {} }; })()}}"
},
"onSuccess": [
{
@@ -1,641 +0,0 @@
{
"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,175 @@
{
"meta": {
"is_partial": true,
"description": "게시판 알림 정의 기본값 복원 확인 모달 (스피너 포함)"
},
"id": "modal_board_notification_definition_reset",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.settings.notification_definitions.reset_modal.title",
"size": "small"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.reset_modal.confirm_message"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 p-3 bg-gray-100 dark:bg-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 block mb-1" },
"text": "$t:sirsoft-board.admin.settings.notification_definitions.reset_modal.target_label"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-medium text-gray-900 dark:text-white" },
"text": "{{_global.resetTargetName ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.reset_modal.warning_message"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
},
"children": [
{
"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 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isResettingNotification}}"
},
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "modal_board_notification_definition_reset"
}
],
"text": "$t:common.cancel"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isResettingNotification}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"isResettingNotification": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/notification-definitions/{{_global.resetTargetId}}/reset",
"params": { "method": "POST" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"isResettingNotification": false,
"resetTargetId": null,
"resetTargetName": ""
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "boardNotificationDefinitions" }
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.settings.notification_definitions.reset_success"
}
},
{
"handler": "closeModal",
"target": "modal_board_notification_definition_reset"
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"isResettingNotification": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message ?? '$t:sirsoft-board.admin.settings.notification_definitions.reset_failed'}}"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_global.isResettingNotification}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.isResettingNotification ? '$t:sirsoft-board.admin.settings.notification_definitions.reset_modal.btn_confirming' : '$t:sirsoft-board.admin.settings.notification_definitions.reset_modal.btn_confirm'}}"
}
]
}
]
}
]
}
@@ -0,0 +1,915 @@
{
"meta": {
"is_partial": true,
"description": "이커머스 알림 템플릿 편집 모달"
},
"id": "modal_board_notification_template_edit",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.title",
"size": "xl",
"closeOnOverlayClick": false
},
"children": [
{
"id": "board_template_form_container",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{$parent._local.isSaving}}",
"props": {
"className": "flex flex-col max-h-[calc(90vh-140px)] -my-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 overflow-y-auto space-y-5 pr-1"
},
"children": [
{
"id": "board_template_validation_error",
"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.notification_definitions.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}}"
}
]
}
]
}
]
}
]
},
{
"id": "board_template_type_channel",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-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.notification_definitions.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.notification_definitions.types.{{$parent._local.editingDefinition?.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.editingDefinition?.type ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-400 dark:text-gray-500"
},
"text": "·"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.channel_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-indigo-600 dark:text-indigo-400"
},
"text": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === $parent._local.editingTemplate?.channel)?.name?.[$locale] ?? $parent._local.editingTemplate?.channel ?? ''}}"
}
]
},
{
"id": "board_template_recipients_section",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-700 rounded-lg p-4 space-y-3"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-medium text-purple-800 dark:text-purple-200"
},
"text": "$t:admin.settings.notification_definitions.recipients.title"
},
{
"type": "basic",
"name": "Div",
"props": { "className": "space-y-3" },
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{$parent._local.editingRecipients ?? []}}",
"item_var": "rcpt",
"index_var": "rcptIdx"
},
"props": {
"className": "bg-white dark:bg-gray-800 border border-purple-200 dark:border-purple-600 rounded-lg p-3 space-y-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-center justify-between" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-xs font-medium text-purple-700 dark:text-purple-300" },
"text": "$t:admin.settings.notification_definitions.recipients.rule_label|index={{rcptIdx + 1}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "fa-solid fa-xmark", "size": "sm" }
}
],
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingRecipients": "{{($parent._local.editingRecipients ?? []).filter((_, i) => i !== rcptIdx)}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "space-y-2" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "space-y-1" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-xs font-medium text-gray-600 dark:text-gray-400" },
"text": "$t:admin.settings.notification_definitions.recipients.type_label"
},
{
"type": "composite",
"name": "Select",
"props": {
"value": "{{rcpt.type ?? ''}}",
"className": "w-full text-sm",
"options": [
{ "value": "trigger_user", "label": "$t:admin.settings.notification_definitions.recipients.types.trigger_user" },
{ "value": "related_user", "label": "$t:admin.settings.notification_definitions.recipients.types.related_user" },
{ "value": "role", "label": "$t:admin.settings.notification_definitions.recipients.types.role" },
{ "value": "specific_users", "label": "$t:admin.settings.notification_definitions.recipients.types.specific_users" }
]
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingRecipients": "{{($parent._local.editingRecipients ?? []).map((r, i) => i === rcptIdx ? {...r, type: $event.target.value} : r)}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{rcpt.type === 'related_user'}}",
"props": { "className": "space-y-1" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-xs font-medium text-gray-600 dark:text-gray-400" },
"text": "$t:admin.settings.notification_definitions.recipients.relation_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block w-full px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600 rounded-md"
},
"text": "{{rcpt.relation ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{rcpt.type === 'role'}}",
"props": { "className": "space-y-1" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-xs font-medium text-gray-600 dark:text-gray-400" },
"text": "$t:admin.settings.notification_definitions.recipients.role_label"
},
{
"type": "composite",
"name": "SearchableDropdown",
"props": {
"value": "{{rcpt.value ?? ''}}",
"options": "{{$parent._local.recipientRoleSearchResults ?? []}}",
"searchPlaceholder": "$t:admin.settings.notification_definitions.recipients.role_search_placeholder",
"noResultsText": "$t:admin.settings.notification_definitions.recipients.users_no_options",
"initialText": "$t:admin.settings.notification_definitions.recipients.role_search_placeholder",
"triggerLabel": "$t:admin.settings.notification_definitions.recipients.role_label",
"className": "w-full"
},
"actions": [
{
"event": "onSearch",
"handler": "apiCall",
"debounce": 300,
"auth_required": true,
"target": "/api/admin/roles",
"params": {
"method": "GET",
"query": {
"search": "{{$args[0]}}",
"per_page": 50
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"recipientRoleSearchResults": "{{(response.data?.data ?? []).map(r => ({ value: r.identifier, label: r.name, description: r.identifier }))}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"recipientRoleSearchResults": []
}
}
]
},
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingRecipients": "{{($parent._local.editingRecipients ?? []).map((r, i) => i === rcptIdx ? {...r, value: $args[0]} : r)}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{rcpt.type === 'specific_users'}}",
"props": { "className": "space-y-1" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-xs font-medium text-gray-600 dark:text-gray-400" },
"text": "$t:admin.settings.notification_definitions.recipients.users_label"
},
{
"id": "ecom_rcpt_users_{{rcptIdx}}",
"type": "composite",
"name": "SearchableDropdown",
"props": {
"multiple": true,
"value": "{{rcpt.value ?? []}}",
"options": "{{$parent._local.recipientUserSearchResults ?? []}}",
"searchPlaceholder": "$t:admin.settings.notification_definitions.recipients.users_placeholder",
"noResultsText": "$t:admin.settings.notification_definitions.recipients.users_no_options",
"initialText": "$t:admin.settings.notification_definitions.recipients.users_placeholder",
"triggerLabel": "$t:admin.settings.notification_definitions.recipients.users_label",
"className": "w-full"
},
"actions": [
{
"event": "onSearch",
"handler": "apiCall",
"debounce": 300,
"auth_required": true,
"target": "/api/admin/users/search",
"params": {
"method": "GET",
"query": {
"keyword": "{{$args[0]}}"
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"recipientUserSearchResults": "{{(response.data ?? []).map(u => ({ value: u.uuid, label: u.name, description: u.email }))}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"recipientUserSearchResults": []
}
}
]
},
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingRecipients": "{{($parent._local.editingRecipients ?? []).map((r, i) => i === rcptIdx ? {...r, value: $args[0]} : r)}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{rcpt.type === 'role' || rcpt.type === 'specific_users'}}",
"props": { "className": "flex items-center gap-2" },
"children": [
{
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{rcpt.exclude_trigger_user ?? false}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingRecipients": "{{($parent._local.editingRecipients ?? []).map((r, i) => i === rcptIdx ? {...r, exclude_trigger_user: !(r.exclude_trigger_user ?? false)} : r)}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-xs text-gray-600 dark:text-gray-400" },
"text": "$t:admin.settings.notification_definitions.recipients.exclude_trigger_user"
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium"
},
"text": "$t:admin.settings.notification_definitions.recipients.add_rule",
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingRecipients": "{{[...($parent._local.editingRecipients ?? []), {type: 'trigger_user'}]}}"
}
}
]
}
]
},
{
"id": "board_template_lang_tabs",
"type": "basic",
"name": "Div",
"props": {
"className": "flex border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Button",
"iteration": {
"source": "{{$locales}}",
"item_var": "loc"
},
"props": {
"type": "button",
"className": "{{($parent._local.editLang ?? $locale) === loc ? '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": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}",
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "$parent._local",
"editLang": "{{loc}}"
}
}
]
}
]
},
{
"id": "board_template_subject_input",
"type": "basic",
"name": "Div",
"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.notification_definitions.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?.[$parent._local.editLang ?? $locale] ?? ''}}",
"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 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingTemplate": "{{Object.assign({}, $parent._local.editingTemplate, {subject: Object.assign({}, $parent._local.editingTemplate?.subject, {[$parent._local.editLang ?? $locale]: $event.target.value})})}}"
}
}
]
}
]
},
{
"id": "board_template_body_input",
"type": "basic",
"name": "Div",
"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.notification_definitions.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?.[$parent._local.editLang ?? $locale] ?? ''}}",
"isHtml": true,
"showHtmlModeToggle": false,
"className": "min-h-[200px]"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingTemplate": "{{Object.assign({}, $parent._local.editingTemplate, {body: Object.assign({}, $parent._local.editingTemplate?.body, {[$parent._local.editLang ?? $locale]: $event.target.value})})}}"
}
}
]
}
]
},
{
"id": "board_template_click_url_input",
"type": "basic",
"name": "Div",
"if": "{{$parent._local.editingTemplate?.channel === 'database'}}",
"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:admin.settings.notification_definitions.edit_modal.click_url"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"value": "{{$parent._local.editingTemplate?.click_url ?? ''}}",
"placeholder": "/mypage/orders/{order_number}",
"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 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "$parent._local",
"editingTemplate": "{{Object.assign({}, $parent._local.editingTemplate, {click_url: $event.target.value || null})}}"
}
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:admin.settings.notification_definitions.edit_modal.click_url_hint"
}
]
},
{
"id": "board_template_variables_info",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editingDefinition?.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.notification_definitions.edit_modal.variables_title"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{$parent._local.editingDefinition?.variables ?? []}}",
"item_var": "v"
},
"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",
"props": { "className": "font-mono" },
"text": "{"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono" },
"text": "{{v.key}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono" },
"text": "}"
},
{
"type": "basic",
"name": "Span",
"text": " — {{v.description ?? ''}}"
}
]
}
]
}
]
}
]
},
{
"id": "board_template_modal_footer",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between items-center pt-4 pb-4 border-t border-gray-200 dark:border-gray-700 mt-4 flex-shrink-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"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:hover:bg-gray-700 transition-colors",
"disabled": "{{$parent._local.isSaving ?? false}}"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.btn_preview",
"actions": [
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/notification-templates/preview",
"params": {
"method": "POST",
"body": {
"definition_id": "{{$parent._local.editingDefinition?.id}}",
"subject": "{{$parent._local.editingTemplate?.subject}}",
"body": "{{$parent._local.editingTemplate?.body}}",
"locale": "{{$parent._local.editLang ?? $locale}}"
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"previewData": "{{response.data}}"
}
},
{
"handler": "openModal",
"target": "modal_board_notification_template_preview"
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-2"
},
"children": [
{
"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: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_notification_template_edit"
}
]
},
{
"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._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/admin/notification-templates/' + $parent._local.editingTemplate?.id}}",
"params": {
"method": "PUT",
"body": {
"subject": "{{$parent._local.editingTemplate?.subject}}",
"body": "{{$parent._local.editingTemplate?.body}}",
"click_url": "{{$parent._local.editingTemplate?.click_url ?? null}}",
"recipients": "{{$parent._local.editingRecipients ?? []}}"
}
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "$parent._local",
"isSaving": false,
"templateErrors": null,
"editingTemplate": null,
"editingDefinition": null,
"editingRecipients": null
}
},
{
"handler": "closeModal",
"target": "modal_board_notification_template_edit"
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boardNotificationDefinitions"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-board.admin.settings.notification_definitions.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.notification_definitions.save_error",
"type": "error"
}
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -3,11 +3,11 @@
"is_partial": true,
"description": "게시판 메일 템플릿 미리보기 모달"
},
"id": "modal_board_mail_template_preview",
"id": "modal_board_notification_template_preview",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.settings.mail_templates.preview_modal.title",
"title": "$t:sirsoft-board.admin.settings.notification_definitions.preview_modal.title",
"size": "lg"
},
"children": [
@@ -32,7 +32,7 @@
"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"
"text": "$t:sirsoft-board.admin.settings.notification_definitions.preview_modal.subject_label"
},
{
"type": "basic",
@@ -40,7 +40,7 @@
"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 ?? ''}}"
"text": "{{$parent._local.previewData?.subject ?? ''}}"
}
]
},
@@ -57,14 +57,15 @@
"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"
"text": "$t:sirsoft-board.admin.settings.notification_definitions.preview_modal.body_label"
},
{
"type": "basic",
"name": "Div",
"type": "composite",
"name": "HtmlContent",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden",
"dangerouslySetInnerHTML": "{{_local.previewData?.body ?? ''}}"
"content": "{{$parent._local.previewData?.body ?? ''}}",
"isHtml": true,
"className": "prose dark:prose-invert prose-sm max-w-none border border-gray-200 dark:border-gray-700 rounded-md p-4 max-h-[400px] overflow-y-auto bg-white dark:bg-gray-900"
}
}
]
@@ -89,7 +90,7 @@
{
"type": "click",
"handler": "closeModal",
"target": "modal_board_mail_template_preview"
"target": "modal_board_notification_template_preview"
}
]
}
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -2883,7 +2883,7 @@
"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']}}"
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('notify_author') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'notify_author') : [...(_local.bulkApplyFields ?? []), 'notify_author']}}"
}
}
],
@@ -2928,19 +2928,6 @@
}
}
]
},
{
"comment": "알림 채널 - if 조건 제거, 항상 표시",
"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" } }
]
}
]
},
@@ -2972,7 +2959,7 @@
"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']}}"
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('notify_admin_on_post') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'notify_admin_on_post') : [...(_local.bulkApplyFields ?? []), 'notify_admin_on_post']}}"
}
}
],
@@ -3017,19 +3004,6 @@
}
}
]
},
{
"comment": "알림 채널 - if 조건 제거, 항상 표시",
"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" } }
]
}
]
}
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -57,7 +57,7 @@
"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']}}"
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('notify_author') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'notify_author') : [...(_local.bulkApplyFields ?? []), 'notify_author']}}"
}
}
],
@@ -96,16 +96,6 @@
}
}
]
},
{
"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" } }
]
}
]
},
@@ -132,7 +122,7 @@
"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']}}"
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('notify_admin_on_post') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'notify_admin_on_post') : [...(_local.bulkApplyFields ?? []), 'notify_admin_on_post']}}"
}
}
],
@@ -171,16 +161,6 @@
}
}
]
},
{
"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" } }
]
}
]
}
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'general'}}",
"props": {
"className": "mt-6 space-y-6"
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -1,721 +0,0 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 메일 템플릿 탭 콘텐츠"
},
"id": "tab_content_mail_templates",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'mail_templates'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
"readonly": "opacity-60",
"normal": ""
}
},
"children": [
{
"id": "board_mail_templates_header",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.title"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.description"
}
]
}
]
},
{
"id": "board_mail_templates_search",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "composite",
"name": "Select",
"props": {
"name": "boardMailTemplateSearchType",
"className": "w-32 px-3 py-2 bg-gray-100 dark:bg-gray-700 border-0 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 focus:ring-2 focus:ring-indigo-500",
"value": "{{_local.mailTemplateFilter?.searchType || 'all'}}",
"options": [
{
"value": "all",
"label": "$t:sirsoft-board.admin.settings.mail_templates.search_type_all"
},
{
"value": "subject",
"label": "$t:sirsoft-board.admin.settings.mail_templates.search_type_subject"
},
{
"value": "body",
"label": "$t:sirsoft-board.admin.settings.mail_templates.search_type_body"
}
]
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"mailTemplateFilter.searchType": "{{$event.target.value}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "relative flex-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fa-solid fa-search",
"className": "absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 text-sm pointer-events-none"
}
},
{
"type": "basic",
"name": "Input",
"props": {
"name": "boardMailTemplateSearch",
"type": "text",
"placeholder": "$t:sirsoft-board.admin.settings.mail_templates.search_placeholder",
"className": "w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500",
"value": "{{_local.mailTemplateFilter?.search || ''}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"mailTemplateFilter.search": "{{$event.target.value}}"
}
},
{
"type": "keydown",
"key": "Enter",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"query": {
"tab": "mail_templates",
"page": 1,
"search": "{{_local.mailTemplateFilter?.search || undefined}}",
"search_type": "{{_local.mailTemplateFilter?.searchType !== 'all' ? _local.mailTemplateFilter?.searchType : undefined}}",
"per_page": "{{query.per_page || undefined}}"
}
}
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.btn_search",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"query": {
"tab": "mail_templates",
"page": 1,
"search": "{{_local.mailTemplateFilter?.search || undefined}}",
"search_type": "{{_local.mailTemplateFilter?.searchType !== 'all' ? _local.mailTemplateFilter?.searchType : undefined}}",
"per_page": "{{query.per_page || undefined}}"
}
}
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.btn_reset_filter",
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"mailTemplateFilter": {
"search": "",
"searchType": "all"
}
}
},
{
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"query": {
"tab": "mail_templates"
},
"replace": true
}
}
]
}
]
}
]
}
]
},
{
"id": "board_mail_templates_info",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.total_count|count={{boardMailTemplates?.data?.pagination?.total ?? 0}}"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.per_page_label"
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "boardMailTemplatePerPage",
"className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"value": "{{String(query.per_page || 20)}}",
"options": [
{
"value": "10",
"label": "10"
},
{
"value": "20",
"label": "20"
},
{
"value": "50",
"label": "50"
},
{
"value": "100",
"label": "100"
}
]
},
"actions": [
{
"type": "change",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
}
}
}
]
}
]
}
]
},
{
"id": "board_mail_templates_list",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 gap-4"
},
"children": [
{
"id": "board_template_card",
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{boardMailTemplates?.data?.data ?? []}}",
"item_var": "tpl",
"index_var": "tplIdx"
},
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-5 flex items-start gap-3"
},
"children": [
{
"id": "board_btn_expand_{{tplIdx}}",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-shrink-0 pt-0.5"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "p-1.5 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{(_local.expandedMailTemplateIds ?? []).includes(tpl.id) ? 'fa-solid fa-chevron-up' : 'fa-solid fa-chevron-down'}}",
"className": "text-sm"
}
}
],
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"expandedMailTemplateIds": "{{(_local.expandedMailTemplateIds ?? []).includes(tpl.id) ? (_local.expandedMailTemplateIds ?? []).filter(id => id !== tpl.id) : [...(_local.expandedMailTemplateIds ?? []), tpl.id]}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start justify-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-1"
},
"children": [
{
"type": "basic",
"name": "H4",
"if": "{{tpl.is_default}}",
"props": {
"className": "text-base font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.types.{{tpl.type}}"
},
{
"type": "basic",
"name": "H4",
"if": "{{!tpl.is_default}}",
"props": {
"className": "text-base font-medium text-gray-900 dark:text-white"
},
"text": "{{tpl.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": "{{tpl.type}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "{{tpl.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'}} text-xs font-medium px-2 py-0.5 rounded-full"
},
"text": "{{tpl.is_active ? '$t:sirsoft-board.admin.settings.mail_templates.active' : '$t:sirsoft-board.admin.settings.mail_templates.inactive'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{tpl.is_default}}",
"props": {
"className": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 text-xs font-medium px-2 py-0.5 rounded-full"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.default_badge"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 truncate"
},
"text": "{{tpl.subject?.[$locale] ?? tpl.subject?.ko ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 ml-4 flex-shrink-0"
},
"children": [
{
"id": "board_btn_toggle_{{tplIdx}}",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 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": "{{_computed.isReadOnly}}"
},
"text": "{{tpl.is_active ? '$t:sirsoft-board.admin.settings.mail_templates.btn_deactivate' : '$t:sirsoft-board.admin.settings.mail_templates.btn_activate'}}",
"actions": [
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/mail-templates/{{tpl.id}}/toggle-active",
"params": {
"method": "PATCH"
},
"onSuccess": [
{
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {}
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-board.admin.settings.mail_templates.toggle_success",
"type": "success"
}
}
]
}
]
},
{
"id": "board_btn_edit_{{tplIdx}}",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors",
"disabled": "{{_computed.isReadOnly}}"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.btn_edit",
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"editingTemplate": "{{tpl}}",
"editLang": "{{$locale}}",
"templateErrors": null,
"isSaving": false
}
},
{
"handler": "openModal",
"target": "modal_board_mail_template_edit"
}
]
}
]
},
{
"id": "board_btn_reset_{{tplIdx}}",
"type": "basic",
"name": "Button",
"if": "{{!tpl.is_default}}",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md border border-orange-300 dark:border-orange-600 text-orange-700 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30 transition-colors",
"disabled": "{{_computed.isReadOnly}}"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.btn_reset",
"actions": [
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/mail-templates/{{tpl.id}}/reset",
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {}
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-board.admin.settings.mail_templates.reset_success",
"type": "success"
}
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(tpl.variables ?? []).length > 0}}",
"props": {
"className": "mt-3 flex flex-wrap gap-1.5"
},
"children": [
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{tpl.variables ?? []}}",
"item_var": "variable",
"index_var": "varIdx"
},
"props": {
"className": "inline-flex items-center text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded font-mono"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{"
},
{
"type": "basic",
"name": "Span",
"text": "{{variable.key}}"
},
{
"type": "basic",
"name": "Span",
"text": "} - {{variable.description ?? ''}}"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(_local.expandedMailTemplateIds ?? []).includes(tpl.id)}}",
"props": {
"className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-2"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.body"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-xs text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono"
},
"text": "{{tpl.body?.[$locale] ?? tpl.body?.ko ?? ''}}"
}
]
}
]
}
]
}
]
},
{
"id": "board_mail_templates_empty",
"type": "basic",
"name": "Div",
"if": "{{(boardMailTemplates?.data?.data ?? []).length === 0}}",
"props": {
"className": "text-center py-12 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fa-solid fa-envelope",
"className": "text-4xl text-gray-300 dark:text-gray-600 mb-3"
}
},
{
"type": "basic",
"name": "P",
"if": "{{query.search || query.search_type}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.empty_no_results"
},
{
"type": "basic",
"name": "P",
"if": "{{!query.search && !query.search_type}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.empty_message"
}
]
},
{
"id": "board_mail_templates_pagination",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-center"
},
"children": [
{
"type": "composite",
"name": "Pagination",
"props": {
"currentPage": "{{_local.mailTemplateCurrentPage ?? 1}}",
"totalPages": "{{_local.mailTemplateTotalPages ?? 1}}",
"totalItems": "{{_local.mailTemplateTotalItems ?? 0}}",
"perPage": "{{boardMailTemplates?.data?.pagination?.per_page || 20}}"
},
"actions": [
{
"event": "onPageChange",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "_local",
"mailTemplateCurrentPage": "{{$args[0]}}"
}
},
{
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
}
}
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,707 @@
{
"meta": {
"is_partial": true,
"description": "게시판 알림 설정 탭 — 채널 토글 + 2단 탭 + 채널별 템플릿 목록"
},
"id": "tab_content_notification_definitions",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'notification_definitions'}}",
"props": {
"className": "mt-6 space-y-6"
},
"children": [
{
"id": "board_notif_header",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.title"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.description"
}
]
},
{
"id": "board_channel_toggles",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.channel_management"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{availableChannels?.data?.channels ?? []}}",
"item_var": "ch",
"index_var": "chIdx"
},
"props": {
"className": "flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{ch.icon ?? 'fas fa-bell'}}",
"className": "text-gray-500 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{ch.name?.[$locale] ?? ch.name?.ko ?? ch.id}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-400 dark:text-gray-500"
},
"text": "{{ch.source_label?.[$locale] ?? ch.source_label?.ko ?? ch.source}}"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{(_local.form?.notifications?.channels ?? []).find(c => c.id === ch.id)?.is_active ?? false}}",
"disabled": "{{_computed.isNotificationReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}",
"hasChanges": true
}
}
]
}
]
}
]
}
]
},
{
"id": "board_channel_sub_tabs",
"type": "basic",
"name": "Div",
"props": {
"className": "flex border-b border-gray-200 dark:border-gray-700 overflow-x-auto"
},
"children": [
{
"type": "basic",
"name": "Button",
"iteration": {
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}",
"item_var": "ch",
"index_var": "chIdx"
},
"props": {
"type": "button",
"className": "{{(_local.activeNotificationChannel ?? query.channel ?? (availableChannels?.data?.channels ?? [])[0]?.id ?? 'mail') === ch.id ? '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.5 text-sm font-medium whitespace-nowrap flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{ch.icon ?? 'fas fa-bell'}}",
"className": "text-sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{ch.name?.[$locale] ?? ch.name?.ko ?? ch.id}}"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"activeNotificationChannel": "{{ch.id}}"
}
},
{
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"channel": "{{ch.id}}",
"page": 1
}
}
}
]
}
}
]
}
]
},
{
"id": "board_notif_list_info",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label"
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "notifPerPage",
"className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"value": "{{String(query.per_page || 20)}}",
"options": [
{ "value": "10", "label": "10" },
{ "value": "20", "label": "20" },
{ "value": "50", "label": "50" }
]
},
"actions": [
{
"type": "change",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
}
}
}
]
}
]
}
]
},
{
"id": "board_notif_definitions_list",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"iteration": {
"source": "{{boardNotificationDefinitions?.data?.data ?? []}}",
"item_var": "def",
"index_var": "defIdx"
},
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-shrink-0 pt-0.5"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "p-1.5 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{(_local.expandedNotifications ?? {})[def.id] ? 'fa-solid fa-chevron-up' : 'fa-solid fa-chevron-down'}}",
"size": "sm"
}
}
],
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"expandedNotifications": "{{Object.assign({}, _local.expandedNotifications ?? {}, {[def.id]: !(_local.expandedNotifications ?? {})[def.id]})}}"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-2 mb-1"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-base font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.types.{{def.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": "{{def.type}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "{{def.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'}} text-xs font-medium px-2 py-0.5 rounded-full"
},
"text": "{{def.is_active ? '$t:sirsoft-board.admin.settings.notification_definitions.active' : '$t:sirsoft-board.admin.settings.notification_definitions.inactive'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{def.is_default}}",
"props": {
"className": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 text-xs font-medium px-2 py-0.5 rounded-full"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.default_badge"
}
]
},
{
"type": "basic",
"name": "P",
"if": "{{(def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 truncate"
},
"text": "{{(def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))?.subject?.[$locale] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"if": "{{!(def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))}}",
"props": {
"className": "text-sm text-gray-400 dark:text-gray-500 italic"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.no_template_for_channel"
},
{
"id": "board_recipients_badge",
"type": "basic",
"name": "Div",
"if": "{{((def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))?.recipients ?? []).length > 0}}",
"props": {
"className": "flex flex-wrap items-center gap-1.5 mt-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400"
},
"text": "$t:admin.settings.notification_definitions.recipients_label"
},
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{(def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))?.recipients ?? []}}",
"item_var": "rcpt"
},
"props": {
"className": "inline-flex items-center gap-1 text-xs px-2 py-0.5 bg-purple-50 dark:bg-purple-900/30 border border-purple-200 dark:border-purple-700 text-purple-700 dark:text-purple-300 rounded-full"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{rcpt.type === 'trigger_user' ? '$t:admin.settings.notification_definitions.recipients.types.trigger_user' : rcpt.type === 'related_user' ? '$t:admin.settings.notification_definitions.recipients.types.related_user' + (rcpt.relation ? ' (' + rcpt.relation + ')' : '') : rcpt.type === 'role' ? '$t:admin.settings.notification_definitions.recipients.types.role' + ': ' + (rcpt.display_name ?? rcpt.value ?? '') : rcpt.type === 'specific_users' ? '$t:admin.settings.notification_definitions.recipients.types.specific_users' + ': ' + ((rcpt.display_names ?? []).join(', ') || '') : rcpt.type}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{rcpt.exclude_trigger_user}}",
"props": {
"className": "text-purple-500 dark:text-purple-400"
},
"text": "$t:admin.settings.notification_definitions.recipients.badge.exclude_self"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(def.variables ?? []).length > 0}}",
"props": {
"className": "flex flex-wrap gap-1 mt-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{def.variables ?? []}}",
"item_var": "v"
},
"props": {
"className": "text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded font-mono"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{"
},
{
"type": "basic",
"name": "Span",
"text": "{{v.key}}"
},
{
"type": "basic",
"name": "Span",
"text": "}"
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3 flex-shrink-0"
},
"children": [
{
"type": "composite",
"name": "Toggle",
"if": "{{(def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))}}",
"props": {
"checked": "{{(def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))?.is_active ?? false}}",
"disabled": "{{_computed.isNotificationReadOnly}}"
},
"actions": [
{
"type": "change",
"handler": "apiCall",
"auth_required": true,
"target": "{{'/api/admin/notification-templates/' + (def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))?.id + '/toggle-active'}}",
"params": {
"method": "PATCH"
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boardNotificationDefinitions"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-board.admin.settings.notification_definitions.toggle_success",
"type": "success"
}
}
]
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_computed.isNotificationReadOnly}}",
"className": "text-sm px-3 py-1.5 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"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.btn_edit",
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"editingDefinition": "{{def}}",
"editingTemplate": "{{(def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))}}",
"editingRecipients": "{{JSON.parse(JSON.stringify((def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))?.recipients ?? []))}}",
"editLang": "{{$locale}}",
"templateErrors": null,
"isSaving": false
}
},
{
"handler": "openModal",
"target": "modal_board_notification_template_edit"
}
]
}
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{!def.is_default || (def.templates ?? []).some(t => t.is_default === false)}}",
"props": {
"type": "button",
"disabled": "{{_computed.isNotificationReadOnly}}",
"className": "text-sm px-3 py-1.5 rounded-md border border-orange-300 dark:border-orange-600 text-orange-700 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.btn_reset",
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"resetTargetId": "{{def.id}}",
"resetTargetName": "{{$localized(def.name)}}"
}
},
{
"handler": "openModal",
"target": "modal_board_notification_definition_reset"
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{(_local.expandedNotifications ?? {})[def.id]}}",
"props": {
"className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 block"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.body_preview"
},
{
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{(def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))?.body?.[$locale] ?? ''}}",
"isHtml": true,
"className": "prose dark:prose-invert prose-sm max-w-none bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 max-h-48 overflow-y-auto dark:text-white"
}
}
]
}
]
}
]
}
]
}
]
},
{
"id": "board_notif_empty",
"type": "basic",
"name": "Div",
"if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}",
"props": {
"className": "text-center py-12 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fa-solid fa-envelope",
"className": "text-4xl text-gray-300 dark:text-gray-600 mb-3"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message"
}
]
},
{
"id": "board_notif_pagination",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-center"
},
"children": [
{
"type": "composite",
"name": "Pagination",
"props": {
"currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}",
"totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}"
},
"actions": [
{
"event": "onPageChange",
"type": "action",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
}
}
}
]
}
]
}
]
}
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'report_policy'}}",
"props": {
"className": "mt-6 space-y-6"
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -567,16 +567,6 @@
}
}
]
},
{
"id": "field_notify_admin_on_report_channels",
"type": "basic",
"name": "Div",
"props": { "className": "space-y-1.5" },
"children": [
{ "type": "basic", "name": "Label", "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-board.admin.settings.notification_channels.channels_label" },
{ "type": "composite", "name": "TagInput", "props": { "name": "report_policy.notify_admin_on_report_channels", "options": "{{notification_channels?.data ?? []}}", "className": "w-full" } }
]
}
]
}
@@ -606,25 +596,6 @@
},
{ "type": "composite", "name": "Toggle", "props": { "name": "report_policy.notify_author_on_report_action", "className": "flex-shrink-0 mt-0.5" } }
]
},
{
"comment": "하위 옵션 (토글 ON 시에만 표시)",
"type": "basic",
"name": "Div",
"if": "{{_local.form?.report_policy?.notify_author_on_report_action === true}}",
"props": { "className": "px-4 py-4 border-t border-gray-200 dark:border-gray-700" },
"children": [
{
"id": "field_notify_author_on_report_action_channels",
"type": "basic",
"name": "Div",
"props": { "className": "space-y-1.5" },
"children": [
{ "type": "basic", "name": "Label", "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-board.admin.settings.notification_channels.channels_label" },
{ "type": "composite", "name": "TagInput", "props": { "name": "report_policy.notify_author_on_report_action_channels", "options": "{{notification_channels?.data ?? []}}", "className": "w-full" } }
]
}
]
}
]
}
@@ -0,0 +1,728 @@
{
"meta": {
"is_partial": true,
"description": "게시판 설정 SEO 탭 콘텐츠"
},
"id": "tab_content_seo",
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'seo'}}",
"props": {
"className": "space-y-6"
},
"children": [
{
"id": "meta_settings_card",
"type": "basic",
"name": "Div",
"props": {
"className": "card"
},
"children": [
{
"id": "meta_settings_header",
"type": "basic",
"name": "Div",
"props": {
"className": "card-header"
},
"children": [
{
"id": "meta_settings_title",
"type": "basic",
"name": "H3",
"props": {
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.title"
},
{
"id": "meta_settings_description",
"type": "basic",
"name": "P",
"props": {
"className": "card-description"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.description"
}
]
},
{
"id": "meta_accordions",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"id": "accordion_boards_page",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion"
},
"children": [
{
"id": "accordion_boards_page_header",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion-header"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "card-accordion-title"
},
"text": "$t:sirsoft-board.admin.settings.seo.boards_page.title"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chevron-down",
"className": "card-accordion-icon open"
}
}
]
},
{
"id": "accordion_boards_page_body",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion-body space-y-4"
},
"children": [
{
"id": "field_boards_title",
"type": "basic",
"name": "Div",
"props": {
"className": "form-group"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.meta_title"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "seo.meta_boards_title",
"placeholder": "$t:sirsoft-board.admin.settings.seo.boards_page.title_placeholder",
"className": "{{_local.errors?.['seo.meta_boards_title'] ? 'input input-error w-full' : 'input w-full'}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['seo.meta_boards_title']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{_local.errors?.['seo.meta_boards_title']?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.available_variables: {site_name}"
}
]
},
{
"id": "field_boards_description",
"type": "basic",
"name": "Div",
"props": {
"className": "form-group"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.meta_description"
},
{
"type": "basic",
"name": "Textarea",
"props": {
"name": "seo.meta_boards_description",
"rows": 3,
"className": "{{_local.errors?.['seo.meta_boards_description'] ? '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 resize-none' : '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 focus:border-blue-500 resize-none'}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['seo.meta_boards_description']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{_local.errors?.['seo.meta_boards_description']?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.available_variables: {site_name}"
}
]
}
]
}
]
},
{
"id": "accordion_board_page",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion"
},
"children": [
{
"id": "accordion_board_page_header",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion-header"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "card-accordion-title"
},
"text": "$t:sirsoft-board.admin.settings.seo.board_page.title"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chevron-down",
"className": "card-accordion-icon open"
}
}
]
},
{
"id": "accordion_board_page_body",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion-body space-y-4"
},
"children": [
{
"id": "field_board_title",
"type": "basic",
"name": "Div",
"props": {
"className": "form-group"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.meta_title"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "seo.meta_board_title",
"placeholder": "$t:sirsoft-board.admin.settings.seo.board_page.title_placeholder",
"className": "{{_local.errors?.['seo.meta_board_title'] ? 'input input-error w-full' : 'input w-full'}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['seo.meta_board_title']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{_local.errors?.['seo.meta_board_title']?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.available_variables: {site_name}, {board_name}"
}
]
},
{
"id": "field_board_description",
"type": "basic",
"name": "Div",
"props": {
"className": "form-group"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.meta_description"
},
{
"type": "basic",
"name": "Textarea",
"props": {
"name": "seo.meta_board_description",
"rows": 3,
"className": "{{_local.errors?.['seo.meta_board_description'] ? '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 resize-none' : '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 focus:border-blue-500 resize-none'}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['seo.meta_board_description']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{_local.errors?.['seo.meta_board_description']?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.available_variables: {site_name}, {board_name}, {board_description}"
}
]
}
]
}
]
},
{
"id": "accordion_post_page",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion"
},
"children": [
{
"id": "accordion_post_page_header",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion-header"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "card-accordion-title"
},
"text": "$t:sirsoft-board.admin.settings.seo.post_page.title"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "chevron-down",
"className": "card-accordion-icon open"
}
}
]
},
{
"id": "accordion_post_page_body",
"type": "basic",
"name": "Div",
"props": {
"className": "card-accordion-body space-y-4"
},
"children": [
{
"id": "field_post_title",
"type": "basic",
"name": "Div",
"props": {
"className": "form-group"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.meta_title"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "seo.meta_post_title",
"placeholder": "$t:sirsoft-board.admin.settings.seo.post_page.title_placeholder",
"className": "{{_local.errors?.['seo.meta_post_title'] ? 'input input-error w-full' : 'input w-full'}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['seo.meta_post_title']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{_local.errors?.['seo.meta_post_title']?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.available_variables: {site_name}, {board_name}, {post_title}"
}
]
},
{
"id": "field_post_description",
"type": "basic",
"name": "Div",
"props": {
"className": "form-group"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.meta_description"
},
{
"type": "basic",
"name": "Textarea",
"props": {
"name": "seo.meta_post_description",
"rows": 3,
"className": "{{_local.errors?.['seo.meta_post_description'] ? '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 resize-none' : '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 focus:border-blue-500 resize-none'}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['seo.meta_post_description']}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
},
"text": "{{_local.errors?.['seo.meta_post_description']?.[0] ?? ''}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.settings.seo.meta_settings.available_variables: {site_name}, {board_name}, {post_title}"
}
]
}
]
}
]
}
]
}
]
},
{
"id": "seo_friendly_card",
"type": "basic",
"name": "Div",
"props": {
"className": "card"
},
"children": [
{
"id": "seo_friendly_header",
"type": "basic",
"name": "Div",
"props": {
"className": "card-header"
},
"children": [
{
"id": "seo_friendly_title",
"type": "basic",
"name": "H3",
"props": {
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.title"
},
{
"id": "seo_friendly_description",
"type": "basic",
"name": "P",
"props": {
"className": "card-description"
},
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.description"
}
]
},
{
"id": "seo_friendly_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.provide_pages"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "seo.seo_boards",
"className": "checkbox"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-secondary"
},
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.boards"
}
]
},
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "seo.seo_board",
"className": "checkbox"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-secondary"
},
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.board"
}
]
},
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "seo.seo_post_detail",
"className": "checkbox"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-secondary"
},
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.post_detail"
}
]
}
]
},
{
"id": "divider_cache",
"type": "basic",
"name": "Div",
"props": {
"className": "divider"
}
},
{
"id": "cache_section",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.cache_management"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-label-subtle mb-3"
},
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.cache_description"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "btn btn-danger",
"disabled": "{{_local.clearingCache ?? false}}"
},
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.clearingCache ?? false}}",
"props": {
"name": "circle-notch",
"className": "fa-spin mr-2"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"clearingCache": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/seo/clear-cache",
"params": {
"method": "POST",
"body": { "layout": "board/boards" }
},
"onSuccess": {
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/seo/clear-cache",
"params": {
"method": "POST",
"body": { "layout": "board/index" }
},
"onSuccess": {
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/seo/clear-cache",
"params": {
"method": "POST",
"body": { "layout": "board/show" }
},
"onSuccess": [
{ "handler": "setState", "params": { "target": "local", "clearingCache": false } },
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_success" } }
],
"onError": [
{ "handler": "setState", "params": { "target": "local", "clearingCache": false } },
{ "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error" } }
]
},
"onError": [
{ "handler": "setState", "params": { "target": "local", "clearingCache": false } },
{ "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error" } }
]
},
"onError": [
{ "handler": "setState", "params": { "target": "local", "clearingCache": false } },
{ "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-board.admin.settings.seo.seo_friendly.clear_cache_error" } }
]
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'spam_security'}}",
"props": {
"className": "mt-6 space-y-6"
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -269,8 +269,6 @@ class BoardController extends AdminBaseController
$data['description'] = [$fallbackLocale => ''];
$data['categories'] = [];
$data['notify_author_channels'] = $basicDefaults['notify_author_channels'] ?? [];
$data['notify_admin_on_post_channels'] = $basicDefaults['notify_admin_on_post_channels'] ?? [];
$spamSecurity = g7_module_settings('sirsoft-board', 'spam_security', []);
$data['blocked_keywords'] = collect($spamSecurity['blocked_keywords'] ?? [])->join(',');
$data['allowed_extensions'] = collect($basicDefaults['allowed_extensions'] ?? [])->join(',');
@@ -1,176 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\MailTemplate\MailTemplateIndexRequest;
use App\Http\Requests\MailTemplate\MailTemplatePreviewRequest;
use App\Http\Requests\MailTemplate\UpdateMailTemplateRequest;
use App\Http\Resources\MailTemplateCollection;
use App\Http\Resources\MailTemplateResource;
use Modules\Sirsoft\Board\Models\BoardMailTemplate;
use Modules\Sirsoft\Board\Services\BoardMailTemplateService;
use Illuminate\Http\JsonResponse;
/**
* 게시판 메일 템플릿 관리 컨트롤러
*/
class BoardMailTemplateController extends AdminBaseController
{
/**
* BoardMailTemplateController 생성자.
*
* @param BoardMailTemplateService $service 메일 템플릿 서비스
*/
public function __construct(
private BoardMailTemplateService $service
) {
parent::__construct();
}
/**
* 메일 템플릿 목록을 페이지네이션하여 조회합니다.
*
* @param MailTemplateIndexRequest $request 검증된 목록 조회 요청
* @return JsonResponse 페이지네이션된 템플릿 목록
*/
public function index(MailTemplateIndexRequest $request): JsonResponse
{
try {
$filters = array_filter($request->validated(), fn ($value) => $value !== null);
$perPage = (int) ($request->validated('per_page') ?? 20);
$templates = $this->service->getTemplates($filters, $perPage);
$collection = new MailTemplateCollection($templates);
return ResponseHelper::moduleSuccess(
'sirsoft-board',
'messages.mail_template_fetch_success',
$collection->toArray($request)
);
} catch (\Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-board',
'messages.mail_template_fetch_failed',
500
);
}
}
/**
* 메일 템플릿을 수정합니다.
*
* @param UpdateMailTemplateRequest $request 수정 요청
* @param BoardMailTemplate $boardMailTemplate 수정 대상
* @return JsonResponse 수정 결과
*/
public function update(UpdateMailTemplateRequest $request, BoardMailTemplate $boardMailTemplate): JsonResponse
{
try {
$userId = $request->user()?->id;
$template = $this->service->updateTemplate(
$boardMailTemplate,
$request->validated(),
$userId
);
return ResponseHelper::moduleSuccess(
'sirsoft-board',
'messages.mail_template_save_success',
new MailTemplateResource($template)
);
} catch (\Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-board',
'messages.mail_template_save_error',
500
);
}
}
/**
* 메일 템플릿의 활성 상태를 토글합니다.
*
* @param BoardMailTemplate $boardMailTemplate 토글 대상
* @return JsonResponse 토글 결과
*/
public function toggleActive(BoardMailTemplate $boardMailTemplate): JsonResponse
{
try {
$template = $this->service->toggleActive($boardMailTemplate);
return ResponseHelper::moduleSuccess(
'sirsoft-board',
'messages.mail_template_toggle_success',
new MailTemplateResource($template)
);
} catch (\Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-board',
'messages.mail_template_toggle_failed',
500
);
}
}
/**
* 메일 템플릿 미리보기를 반환합니다.
*
* @param MailTemplatePreviewRequest $request 검증된 미리보기 요청
* @return JsonResponse 미리보기 결과
*/
public function preview(MailTemplatePreviewRequest $request): JsonResponse
{
try {
$result = $this->service->getPreview($request->validated());
return ResponseHelper::moduleSuccess(
'sirsoft-board',
'messages.mail_template_preview_success',
$result
);
} catch (\Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-board',
'messages.mail_template_preview_failed',
500
);
}
}
/**
* 메일 템플릿을 시더 기본값으로 복원합니다.
*
* @param BoardMailTemplate $boardMailTemplate 복원 대상
* @return JsonResponse 복원 결과
*/
public function reset(BoardMailTemplate $boardMailTemplate): JsonResponse
{
try {
$defaultData = $this->service->getDefaultTemplateData($boardMailTemplate->type);
if (! $defaultData) {
return ResponseHelper::moduleError(
'sirsoft-board',
'messages.mail_template_reset_no_default',
404
);
}
$template = $this->service->resetToDefault($boardMailTemplate, $defaultData);
return ResponseHelper::moduleSuccess(
'sirsoft-board',
'messages.mail_template_reset_success',
new MailTemplateResource($template)
);
} catch (\Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-board',
'messages.mail_template_reset_failed',
500
);
}
}
}
@@ -3,13 +3,11 @@
namespace Modules\Sirsoft\Board\Http\Controllers\Admin;
use App\Helpers\PermissionHelper;
use App\Extension\HookManager;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Modules\Sirsoft\Board\Http\Requests\Admin\BulkApplySettingsRequest;
use Modules\Sirsoft\Board\Http\Requests\Admin\StoreBoardSettingsRequest;
use Modules\Sirsoft\Board\Services\BoardService;
@@ -172,29 +170,6 @@ class BoardSettingsController extends AdminBaseController
}
}
/**
* 사용 가능한 알림 채널 목록을 반환합니다.
*
* 기본 채널(mail, database)을 제공하며, 플러그인이
* 'sirsoft-board.notification.available_channels' Filter 훅으로 채널을 추가할 수 있습니다.
* 추후 코어에 공통 API가 생기면 이 메서드만 교체하면 됩니다.
*
* @return JsonResponse 채널 목록 JSON 응답
*/
public function notificationChannels(): JsonResponse
{
$defaultChannels = [
['value' => 'mail', 'label' => __('sirsoft-board::admin.settings.notification_channels.channel_mail')],
];
$channels = HookManager::applyFilters(
'sirsoft-board.notification.available_channels',
$defaultChannels
);
return ResponseHelper::success('messages.fetch_success', $channels);
}
/**
* 설정 캐시를 초기화합니다.
*
@@ -208,8 +183,8 @@ class BoardSettingsController extends AdminBaseController
// ModuleSettings 캐시 초기화
$this->settingsService->clearCache();
// 게시판 캐시 초기화 (boards:list)
Cache::forget('boards:list');
// 게시판 캐시 전체 초기화
$this->boardService->clearAllBoardCaches();
return ResponseHelper::moduleSuccess(
'sirsoft-board',
@@ -18,6 +18,7 @@ use Modules\Sirsoft\Board\Http\Resources\PostCollection;
use Modules\Sirsoft\Board\Http\Resources\PostResource;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Services\ReportService;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -35,10 +36,12 @@ class PostController extends AdminBaseController
*
* @param PostService $postService 게시글 서비스
* @param BoardService $boardService 게시판 서비스
* @param ReportService $reportService 신고 서비스
*/
public function __construct(
private PostService $postService,
private BoardService $boardService
private BoardService $boardService,
private ReportService $reportService
) {
parent::__construct();
}
@@ -69,9 +72,11 @@ class PostController extends AdminBaseController
// 삭제된 게시글 포함 여부 (admin.manage 또는 admin.control 권한)
$canViewDeleted = $this->checkBoardPermission($slug, 'admin.manage');
// 게시글 목록 조회
$posts = $this->postService->getPosts($slug, $listParams['filters'], $listParams['perPage'], withTrashed: $canViewDeleted);
$totalNormalPosts = $this->postService->getTotalNormalPosts($slug, $listParams['filters'], $canViewDeleted);
// 게시글 목록 조회 (simplePaginate — COUNT 쿼리 제거)
$posts = $this->postService->getPosts($slug, $listParams['filters'], $listParams['perPage'], withTrashed: $canViewDeleted, board: $board);
// 일반 게시글 총 건수는 캐시에서 조회 (simplePaginate는 total 미제공)
$totalNormalPosts = $this->postService->getCachedNormalPostCount($slug, $board->id, $listParams['filters'], $canViewDeleted, 'admin');
// PostCollection 구성
$collection = new PostCollection($posts);
@@ -123,6 +128,21 @@ class PostController extends AdminBaseController
// 상세 정보 로드 (조회수 증가, 댓글 로드, 이전/다음 게시글 조회)
$post = $this->postService->loadPostDetail($slug, $id, $canViewDeleted);
// 댓글 신고 여부 일괄 사전 로드 (N+1 방지: 댓글별 개별 쿼리 → 1회 일괄 쿼리)
$user = Auth::user();
if ($user && $post->relationLoaded('comments')) {
$comments = $post->comments;
$board = $post->board;
$commentIds = $comments->pluck('id')->all();
$reportedCommentIds = ! empty($commentIds)
? $this->reportService->getReportedTargetIds($user->id, $board->id, 'comment', $commentIds)
: [];
foreach ($comments as $comment) {
$comment->is_already_reported_preloaded = in_array($comment->id, $reportedCommentIds);
}
}
return $this->successWithResource(
'sirsoft-board::messages.posts.fetch_success',
new PostResource($post)
@@ -3,13 +3,10 @@
namespace Modules\Sirsoft\Board\Http\Controllers\User;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Models\User;
use App\Services\CacheService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Sirsoft\Board\Http\Resources\BoardMenuResource;
use Modules\Sirsoft\Board\Http\Resources\BoardResource;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Services\BoardService;
/**
@@ -28,7 +25,9 @@ class BoardController extends PublicBaseController
*/
public function __construct(
protected BoardService $boardService
) {}
) {
parent::__construct();
}
/**
* 활성화된 게시판 목록 조회 (경량)
@@ -53,9 +52,9 @@ class BoardController extends PublicBaseController
$data = $boards->map(function ($board) use ($recentPostsLimit) {
$boardData = (new BoardResource($board))->toListArray();
// limit가 0보다 크면 최신글 조회
// limit가 0보다 크면 최신글 조회 (board_id 직접 전달로 slug 재조회 방지)
if ($recentPostsLimit > 0) {
$boardData['recent_posts'] = $this->boardService->getBoardRecentPosts($board->slug, $recentPostsLimit);
$boardData['recent_posts'] = $this->boardService->getBoardRecentPostsById($board->id, $recentPostsLimit);
}
return $boardData;
@@ -100,10 +99,8 @@ class BoardController extends PublicBaseController
*/
public function boardMenu(): JsonResponse
{
// 활성화된 게시판을 오래된 순으로 조회
$boards = $this->boardService->getActiveBoards(orderBy: 'created_at', orderDirection: 'asc');
$boards = $this->boardService->getCachedActiveBoardsForMenu();
// 경량 리소스로 변환하여 반환
return $this->success(
'common.success',
BoardMenuResource::collection($boards)->resolve()
@@ -122,14 +119,7 @@ class BoardController extends PublicBaseController
*/
public function stats(): JsonResponse
{
$stats = CacheService::remember(
'sirsoft-board',
'stats',
fn () => $this->calculateStats(),
config('sirsoft-board.cache.ttl', 60)
);
return $this->success('common.success', $stats);
return $this->success('common.success', $this->boardService->getCachedStats());
}
/**
@@ -145,14 +135,7 @@ class BoardController extends PublicBaseController
{
$limit = min((int) $request->input('limit', 5), 20);
$posts = CacheService::remember(
'sirsoft-board',
"recent_posts_{$limit}",
fn () => $this->boardService->getRecentPosts($limit),
config('sirsoft-board.cache.ttl', 60)
);
return $this->success('common.success', $posts);
return $this->success('common.success', $this->boardService->getCachedRecentPosts($limit));
}
/**
@@ -160,26 +143,22 @@ class BoardController extends PublicBaseController
*
* 조회수가 많은 순으로 게시글 목록을 반환합니다.
* - 기본 20개, 최대 50개까지 조회 가능
* - 기간별 필터링: today, week, month, all (기본: week)
* - 기간별 필터링: today, week, month, year (기본: week)
* - period=all은 하위 호환을 위해 year로 매핑
*
* @param Request $request
* @return JsonResponse
*/
public function popular(Request $request): JsonResponse
{
$period = $request->input('period', 'week'); // today, week, month, all
$period = $request->input('period', 'week');
// all → year 매핑 (하위 호환)
if ($period === 'all') {
$period = 'year';
}
$limit = min((int) $request->input('limit', 20), 50);
$locale = app()->getLocale();
// 캐시 키에 period, locale 포함
$posts = CacheService::remember(
'sirsoft-board',
"popular_posts_{$locale}_{$period}_{$limit}",
fn () => $this->boardService->getPopularPosts($period, $limit),
config('sirsoft-board.cache.ttl', 60)
);
return $this->success('common.success', $posts);
return $this->success('common.success', $this->boardService->getCachedPopularPosts($period, $limit));
}
/**
@@ -194,30 +173,7 @@ class BoardController extends PublicBaseController
public function popularBoards(Request $request): JsonResponse
{
$limit = min((int) $request->input('limit', 4), 20);
$locale = app()->getLocale();
$boards = CacheService::remember(
'sirsoft-board',
"popular_boards_{$locale}_{$limit}",
fn () => $this->boardService->getPopularBoards($limit),
config('sirsoft-board.cache.ttl', 60)
);
return $this->success('common.success', $boards);
}
/**
* 통계 계산 (홈페이지용)
*
* @return array{users: int, boards: int, posts: int, comments: int}
*/
private function calculateStats(): array
{
return [
'users' => User::count(),
'boards' => Board::where('is_active', true)->count(),
'posts' => $this->boardService->getTotalPostsCount(),
'comments' => $this->boardService->getTotalCommentsCount(),
];
return $this->success('common.success', $this->boardService->getCachedPopularBoards($limit));
}
}
@@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest;
@@ -93,7 +92,7 @@ class CommentController extends PublicBaseController
$cooldown = (int) ($spamSecurity['comment_cooldown_seconds'] ?? 0);
if ($cooldown > 0) {
$identifier = Auth::id() ?? $request->ip();
Cache::put("comment_cooldown_{$slug}_{$identifier}", true, $cooldown);
$this->commentService->recordCommentCooldown($slug, $identifier, $cooldown);
}
return $this->successWithResource(
@@ -8,7 +8,6 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Enums\PermissionType;
@@ -24,6 +23,7 @@ use Modules\Sirsoft\Board\Http\Resources\PostResource;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\CommentService;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Services\ReportService;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -46,11 +46,13 @@ class PostController extends PublicBaseController
* @param PostService $postService 게시글 서비스
* @param BoardService $boardService 게시판 서비스
* @param CommentService $commentService 댓글 서비스
* @param ReportService $reportService 신고 서비스
*/
public function __construct(
private PostService $postService,
private BoardService $boardService,
private CommentService $commentService
private CommentService $commentService,
private ReportService $reportService
) {}
/**
@@ -80,9 +82,12 @@ class PostController extends PublicBaseController
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
$withTrashed = $canViewDeleted && $request->boolean('del');
// 게시글 목록 조회
$posts = $this->postService->getPosts($slug, $listParams['filters'], $listParams['perPage'], withTrashed: $withTrashed, context: 'user');
$totalNormalPosts = $this->postService->getTotalNormalPosts($slug, $listParams['filters'], $withTrashed, context: 'user');
// 게시글 목록 조회 (simplePaginate — COUNT 쿼리 제거)
// board 객체 전달로 Service/Repository의 중복 Board 조회 방지
$posts = $this->postService->getPosts($slug, $listParams['filters'], $listParams['perPage'], withTrashed: $withTrashed, context: 'user', board: $board);
// 일반 게시글 총 건수는 캐시에서 조회 (simplePaginate는 total 미제공)
$totalNormalPosts = $this->postService->getCachedNormalPostCount($slug, $board->id, $listParams['filters'], $withTrashed, 'user');
// PostCollection 구성
$collection = new PostCollection($posts);
@@ -122,7 +127,7 @@ class PostController extends PublicBaseController
throw new BoardNotFoundException($slug);
}
// 게시글 조회
// 게시글 조회 (스코프 접근 검사 포함)
$post = $this->postService->getPost($slug, $id, context: 'user');
// 삭제된 게시글은 manager 권한 필요
@@ -135,8 +140,8 @@ class PostController extends PublicBaseController
// 조회수 증가 (캐시 기반 중복 방지)
$this->postService->incrementViewCountOnce($slug, $id);
// 댓글/첨부파일 카운트 포함하여 게시글 조회
$post = $this->postService->getPostWithCounts($slug, $id);
// 댓글/첨부파일/답글 포함하여 게시글 조회 (boardId 전달로 Board 재조회 방지)
$post = $this->postService->getPostWithCounts($slug, $id, $board->id);
// board 관계 수동 설정
$post->setRelation('board', $board);
@@ -146,20 +151,30 @@ class PostController extends PublicBaseController
// 댓글 로드 (게시판 comment_order 설정 적용, manager 권한 + 토글 ON 시 삭제 댓글 포함)
$withTrashedComments = $canViewDeleted && $request->boolean('del_cmt');
$comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments);
$comments = $this->commentService->getCommentsByPostId($slug, $id, context: 'user', withTrashed: $withTrashedComments, boardId: $board->id);
// 댓글의 post에 board 관계 수동 설정 (CommentResource의 권한 체크에 필요)
foreach ($comments as $comment) {
$comment->setRelation('post', $post);
// 신고 여부 일괄 조회 (N+1 방지: 댓글별 개별 쿼리 → 1회 일괄 쿼리)
$user = $request->user();
if ($user) {
$commentIds = $comments->pluck('id')->all();
$reportedCommentIds = $this->reportService
->getReportedTargetIds($user->id, $board->id, 'comment', $commentIds);
foreach ($comments as $comment) {
$comment->is_already_reported_preloaded = in_array($comment->id, $reportedCommentIds);
$comment->setRelation('post', $post);
}
} else {
// 비로그인: 신고 불가이므로 모두 false
foreach ($comments as $comment) {
$comment->is_already_reported_preloaded = false;
$comment->setRelation('post', $post);
}
}
// 정렬된 댓글을 post에 설정
$post->setRelation('comments', $comments);
// 이전/다음 게시글 조회 (manager 권한 + del=1 시 삭제된 게시글 포함)
$withTrashedNav = $canViewDeleted && $request->boolean('del');
$post->navigation = $this->postService->getAdjacentPosts($slug, $id, filters: [], withTrashed: $withTrashedNav);
// 비밀글 권한 체크 및 content 필터링은 PostResource에서 처리
return $this->successWithResource(
'sirsoft-board::messages.posts.fetch_success',
@@ -176,6 +191,50 @@ class PostController extends PublicBaseController
}
}
/**
* 게시글의 이전/다음 네비게이션 정보를 조회합니다.
*
* 상세 API에서 분리하여 비동기 로딩 지원.
* 게시판 정렬 설정에 따라 이전/다음글을 반환합니다.
*
* @param Request $request HTTP 요청
* @param string $slug 게시판 슬러그
* @param string|int $id 게시글 ID
* @return JsonResponse 이전/다음 게시글 정보
*/
public function navigation(Request $request, string $slug, string|int $id): JsonResponse
{
$id = (int) $id;
try {
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board || ! $board->is_active) {
throw new BoardNotFoundException($slug);
}
// 공지글은 이전/다음 네비게이션 미제공
$post = $this->postService->getPost($slug, $id, context: 'user');
if ($post->is_notice) {
return $this->success('sirsoft-board::messages.posts.fetch_success', ['prev' => null, 'next' => null]);
}
// manager 권한 + del=1 시 삭제된 게시글 포함
$canViewDeleted = $this->checkBoardPermission($slug, 'manager', PermissionType::User);
$withTrashed = $canViewDeleted && $request->boolean('del');
$navigation = $this->postService->getAdjacentPosts($slug, $id, filters: [
'order_by' => $board->order_by instanceof \BackedEnum ? $board->order_by->value : $board->order_by,
'order_direction' => $board->order_direction instanceof \BackedEnum ? $board->order_direction->value : $board->order_direction,
], withTrashed: $withTrashed, board: $board);
return $this->success('sirsoft-board::messages.posts.fetch_success', $navigation);
} catch (BoardNotFoundException $e) {
throw $e;
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.posts.fetch_failed', 500, $e->getMessage());
}
}
/**
* 게시글을 생성합니다.
*
@@ -234,7 +293,7 @@ class PostController extends PublicBaseController
$cooldown = (int) ($spamSecurity['post_cooldown_seconds'] ?? 0);
if ($cooldown > 0) {
$identifier = Auth::id() ?? $request->ip();
Cache::put("post_cooldown_{$slug}_{$identifier}", true, $cooldown);
$this->postService->recordPostCooldown($slug, $identifier, $cooldown);
}
// board 관계 수동 설정
@@ -428,18 +487,17 @@ class PostController extends PublicBaseController
return $this->error($verifyResult['error_key'], $verifyResult['error_code']);
}
// 검증 성공 시 임시 토큰 생성 및 캐시 저장 (1시간 유효)
// 검증 성공 시 임시 토큰 생성 및 캐시 저장
$verificationToken = Str::random(32);
$expiresAt = now()->addHours(1);
Cache::put("board_post_verify_{$slug}_{$id}_{$verificationToken}", true, $expiresAt);
$tokenResult = $this->postService->storeDeleteVerifyToken($slug, $id, $verificationToken);
return $this->success(
'sirsoft-board::messages.posts.password_verified',
[
'verified' => true,
'post_id' => $id,
'verification_token' => $verificationToken,
'expires_at' => $expiresAt->toIso8601String(),
'verification_token' => $tokenResult['token'],
'expires_at' => $tokenResult['expires_at'],
]
);
} catch (ModelNotFoundException $e) {
@@ -511,8 +569,9 @@ class PostController extends PublicBaseController
// 첨부파일 관계 로드
$post->load('attachments');
// 폼용 경량 변환 (abilities/IP/신고 등 불필요한 권한 체크 생략)
$postResource = new PostResource($post);
$postData = $postResource->toArray($request);
$postData = $postResource->toFormArray($request);
$metaData['author'] = $postData['author'] ?? null;
$metaData['created_at'] = $postData['created_at'] ?? null;
@@ -533,7 +592,7 @@ class PostController extends PublicBaseController
$parentPost = $this->postService->getPost($slug, $parentId, context: 'user');
$parentPostResource = new PostResource($parentPost);
$metaData['parent_post'] = $parentPostResource->toArray($request);
$metaData['parent_post'] = $parentPostResource->toFormArray($request);
}
return $this->success('sirsoft-board::messages.posts.form_meta_retrieved', $metaData);
@@ -590,8 +649,9 @@ class PostController extends PublicBaseController
}
}
// 폼용 경량 변환 (abilities/IP/신고 등 불필요한 권한 체크 생략)
$postResource = new PostResource($post);
$postData = $postResource->toArray($request);
$postData = $postResource->toFormArray($request);
$formData = [
'id' => $postData['id'] ?? null,
@@ -699,7 +759,7 @@ class PostController extends PublicBaseController
// =========================================================================
/**
* verification_token이 유효한지 확인합니다.
* verification_token이 유효한지 확인하고 소비합니다.
*
* @param string $slug 게시판 슬러그
* @param int $postId 게시글 ID
@@ -708,7 +768,7 @@ class PostController extends PublicBaseController
*/
private function isVerificationTokenValid(string $slug, int $postId, string $token): bool
{
return (bool) Cache::get("board_post_verify_{$slug}_{$postId}_{$token}");
return $this->postService->consumeDeleteVerifyToken($slug, $postId, $token);
}
// =========================================================================
@@ -6,7 +6,6 @@ use App\Http\Controllers\Api\Base\AuthBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Enums\ReportType;
use Modules\Sirsoft\Board\Exceptions\DuplicateReportException;
@@ -112,7 +111,7 @@ class ReportController extends AuthBaseController
$cooldown = (int) ($security['report_cooldown_seconds'] ?? 60);
if ($cooldown > 0) {
$identifier = Auth::id() ?? $request->ip();
Cache::put("report_cooldown_{$slug}_{$identifier}", true, $cooldown);
$this->reportService->recordReportCooldown($slug, $identifier, $cooldown);
}
$this->logUserActivity('board_report.create', [
@@ -42,9 +42,7 @@ class BulkApplySettingsRequest extends FormRequest
'max_file_count',
'allowed_extensions',
'notify_admin_on_post',
'notify_admin_on_post_channels',
'notify_author',
'notify_author_channels',
'new_display_hours',
];
@@ -57,6 +57,21 @@ class StoreBoardSettingsRequest extends FormRequest
'spam_security.view_count_cache_ttl',
];
$booleanFields = [
'basic_defaults.use_comment',
'basic_defaults.use_reply',
'basic_defaults.show_view_count',
'basic_defaults.use_report',
'basic_defaults.use_file_upload',
'basic_defaults.notify_admin_on_post',
'basic_defaults.notify_author',
'report_policy.notify_admin_on_report',
'report_policy.notify_author_on_report_action',
'seo.seo_boards',
'seo.seo_board',
'seo.seo_post_detail',
];
$data = $this->all();
foreach ($integerFields as $field) {
@@ -66,6 +81,13 @@ class StoreBoardSettingsRequest extends FormRequest
}
}
foreach ($booleanFields as $field) {
[$category, $key] = explode('.', $field, 2);
if (isset($data[$category][$key])) {
$data[$category][$key] = filter_var($data[$category][$key], FILTER_VALIDATE_BOOLEAN);
}
}
$this->replace($data);
}
@@ -78,7 +100,16 @@ class StoreBoardSettingsRequest extends FormRequest
{
return [
// 현재 탭 정보 (메타 데이터)
'_tab' => ['sometimes', 'string', 'in:basic_defaults,report_policy,spam_security,general'],
'_tab' => ['sometimes', 'string', 'in:basic_defaults,report_policy,spam_security,general,seo,notifications,notification_definitions'],
// ========================================
// notifications (알림 채널 설정) 카테고리
// ========================================
'notifications' => ['sometimes', 'array'],
'notifications.channels' => ['sometimes', 'array'],
'notifications.channels.*.id' => ['required_with:notifications.channels', 'string', 'max:50'],
'notifications.channels.*.is_active' => ['required_with:notifications.channels', 'boolean'],
'notifications.channels.*.sort_order' => ['nullable', 'integer', 'min:0'],
// ========================================
// basic_defaults (기본 설정) 카테고리
@@ -112,10 +143,6 @@ class StoreBoardSettingsRequest extends FormRequest
'basic_defaults.allowed_extensions.*' => ['string', 'max:20'],
'basic_defaults.notify_admin_on_post' => ['nullable', 'boolean'],
'basic_defaults.notify_author' => ['nullable', 'boolean'],
'basic_defaults.notify_admin_on_post_channels' => ['nullable', 'array'],
'basic_defaults.notify_admin_on_post_channels.*' => ['string', 'in:mail'],
'basic_defaults.notify_author_channels' => ['nullable', 'array'],
'basic_defaults.notify_author_channels.*' => ['string', 'in:mail'],
'basic_defaults.new_display_hours' => ['nullable', 'integer', 'min:1', 'max:720'],
// default_board_permissions는 flat key 구조 (예: {"posts.read": ["admin","user"], "manager": ["admin"]})
// Laravel dot notation으로 하위 키를 개별 검증하면 flat key가 중첩 배열로 파싱되어 데이터 유실됨
@@ -133,11 +160,7 @@ class StoreBoardSettingsRequest extends FormRequest
'report_policy.rejection_limit_days' => ['nullable', 'integer', 'min:1', 'max:365'],
'report_policy.notify_admin_on_report' => ['nullable', 'boolean'],
'report_policy.notify_admin_on_report_scope' => ['nullable', 'string', 'in:per_case,per_report'],
'report_policy.notify_admin_on_report_channels' => ['nullable', 'array'],
'report_policy.notify_admin_on_report_channels.*' => ['string', 'in:mail'],
'report_policy.notify_author_on_report_action' => ['nullable', 'boolean'],
'report_policy.notify_author_on_report_action_channels' => ['nullable', 'array'],
'report_policy.notify_author_on_report_action_channels.*' => ['string', 'in:mail'],
// ========================================
// report_permissions (신고 관리 권한) — 설정값이 아닌 DB 권한 데이터
@@ -163,6 +186,20 @@ class StoreBoardSettingsRequest extends FormRequest
'spam_security.comment_cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:3600'],
'spam_security.report_cooldown_seconds' => ['nullable', 'integer', 'min:0', 'max:3600'],
'spam_security.view_count_cache_ttl' => ['nullable', 'integer', 'min:60', 'max:604800'],
// ========================================
// seo (SEO 설정) 카테고리
// ========================================
'seo' => ['sometimes', 'array'],
'seo.meta_boards_title' => ['nullable', 'string', 'max:500'],
'seo.meta_boards_description' => ['nullable', 'string', 'max:1000'],
'seo.meta_board_title' => ['nullable', 'string', 'max:500'],
'seo.meta_board_description' => ['nullable', 'string', 'max:1000'],
'seo.meta_post_title' => ['nullable', 'string', 'max:500'],
'seo.meta_post_description' => ['nullable', 'string', 'max:1000'],
'seo.seo_boards' => ['nullable', 'boolean'],
'seo.seo_board' => ['nullable', 'boolean'],
'seo.seo_post_detail' => ['nullable', 'boolean'],
];
}
@@ -201,7 +238,7 @@ class StoreBoardSettingsRequest extends FormRequest
public function validatedSettings(): array
{
$validated = $this->validated();
$validCategories = ['basic_defaults', 'report_policy', 'spam_security', 'display'];
$validCategories = ['basic_defaults', 'report_policy', 'spam_security', 'display', 'seo', 'notifications'];
return array_filter(
$validated,
@@ -51,8 +51,6 @@ class StoreBoardRequest extends FormRequest
'comment_order' => $settings['comment_order'] ?? 'ASC',
'notify_admin_on_post' => $settings['notify_admin_on_post'] ?? true,
'notify_author' => $settings['notify_author'] ?? true,
'notify_author_channels' => $settings['notify_author_channels'] ?? [],
'notify_admin_on_post_channels' => $settings['notify_admin_on_post_channels'] ?? [],
'permissions' => [], // 빈 배열 → Service에서 Manager/Step 주입
];
@@ -193,10 +191,6 @@ class StoreBoardRequest extends FormRequest
// 알림 설정
'notify_admin_on_post' => ['required', 'boolean'],
'notify_author' => ['required', 'boolean'],
'notify_author_channels' => ['nullable', 'array'],
'notify_author_channels.*' => ['string', 'max:50'],
'notify_admin_on_post_channels' => ['nullable', 'array'],
'notify_admin_on_post_channels.*' => ['string', 'max:50'],
// 보안 설정 (배열도 허용)
'blocked_keywords' => ['nullable', 'array'],
@@ -174,10 +174,6 @@ class UpdateBoardRequest extends FormRequest
// 알림 설정
'notify_admin_on_post' => ['sometimes', 'required', 'boolean'],
'notify_author' => ['sometimes', 'required', 'boolean'],
'notify_author_channels' => ['sometimes', 'nullable', 'array'],
'notify_author_channels.*' => ['string', 'max:50'],
'notify_admin_on_post_channels' => ['sometimes', 'nullable', 'array'],
'notify_admin_on_post_channels.*' => ['string', 'max:50'],
// 보안 설정 (배열도 허용)
'blocked_keywords' => ['sometimes', 'nullable', 'array'],
@@ -3,7 +3,7 @@
namespace Modules\Sirsoft\Board\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use App\Http\Resources\BaseApiCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
@@ -12,7 +12,7 @@ use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
*
* 게시판 목록을 페이지네이션과 함께 반환합니다.
*/
class BoardCollection extends ResourceCollection
class BoardCollection extends BaseApiCollection
{
use ChecksBoardPermission;
@@ -28,7 +28,7 @@ class BoardCollection extends ResourceCollection
'data' => $this->collection->map(function ($board) {
return new BoardResource($board);
}),
'pagination' => $this->when($this->resource instanceof LengthAwarePaginator, fn () => [
'pagination' => $this->resource instanceof LengthAwarePaginator ? [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
@@ -36,7 +36,7 @@ class BoardCollection extends ResourceCollection
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
]),
] : null,
];
}
@@ -78,17 +78,12 @@ class BoardResource extends BaseApiResource
// 표시 설정
'new_display_hours' => $this->new_display_hours ?? 24,
// 게시판 관리 인원 (역할 기반)
'board_managers' => $this->getBoardRoleUsers("sirsoft-board.{$this->slug}.manager"),
'board_steps' => $this->getBoardRoleUsers("sirsoft-board.{$this->slug}.step"),
'board_manager_ids' => $this->getBoardRoleUserIds("sirsoft-board.{$this->slug}.manager"),
'board_step_ids' => $this->getBoardRoleUserIds("sirsoft-board.{$this->slug}.step"),
// 게시판 관리 인원 (역할 기반, 역할별 1회 조회로 통합)
...$this->getBoardRoleData("sirsoft-board.{$this->slug}"),
// 알림 설정
'notify_author' => $this->notify_author,
'notify_admin_on_post' => $this->notify_admin_on_post,
'notify_author_channels' => $this->notify_author_channels ?? [],
'notify_admin_on_post_channels' => $this->notify_admin_on_post_channels ?? [],
// 타임스탬프
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
@@ -258,36 +253,48 @@ class BoardResource extends BaseApiResource
* @param string $roleIdentifier 역할 identifier
* @return array 사용자 목록 [{id, name, email}, ...]
*/
private function getBoardRoleUsers(string $roleIdentifier): array
{
$role = \App\Models\Role::where('identifier', $roleIdentifier)->first();
if (! $role) {
return [];
}
return $role->users->map(fn ($user) => [
'uuid' => $user->uuid,
'name' => $user->name,
'email' => $user->email,
])->values()->toArray();
}
/**
* 게시판 역할에 할당된 사용자 ID 목록을 반환합니다.
* 게시판 역할(manager/step) 사용자 정보를 1회 조회로 통합 반환합니다.
*
* @param string $roleIdentifier 역할 identifier
* @return array 사용자 ID 배열
* 기존 getBoardRoleUsers() + getBoardRoleUserIds()가 역할별 2회씩 조회하던 것을
* 역할별 1회 조회로 통합합니다.
*
* @param string $rolePrefix 역할 접두사 (예: "sirsoft-board.free")
* @return array board_managers, board_steps, board_manager_ids, board_step_ids
*/
private function getBoardRoleUserIds(string $roleIdentifier): array
private function getBoardRoleData(string $rolePrefix): array
{
$role = \App\Models\Role::where('identifier', $roleIdentifier)->first();
$result = [
'board_managers' => [],
'board_steps' => [],
'board_manager_ids' => [],
'board_step_ids' => [],
];
if (! $role) {
return [];
$roles = \App\Models\Role::whereIn('identifier', [
"{$rolePrefix}.manager",
"{$rolePrefix}.step",
])->with('users')->get();
foreach ($roles as $role) {
$users = $role->users->map(fn ($user) => [
'uuid' => $user->uuid,
'name' => $user->name,
'email' => $user->email,
])->values()->toArray();
$ids = $role->users->pluck('uuid')->toArray();
if (str_ends_with($role->identifier, '.manager')) {
$result['board_managers'] = $users;
$result['board_manager_ids'] = $ids;
} else {
$result['board_steps'] = $users;
$result['board_step_ids'] = $ids;
}
}
return $role->users->pluck('uuid')->toArray();
return $result;
}
// =========================================================================
@@ -385,18 +392,41 @@ class BoardResource extends BaseApiResource
'can_manage' => "sirsoft-board.{$slug}.manager",
];
// 8개 권한을 일괄 조회 (개별 Permission::where() 8회 → whereIn 1회)
$identifiers = array_values($permissionMap);
$permissions = Permission::whereIn('identifier', $identifiers)->get()->keyBy('identifier');
$result = [];
foreach ($permissionMap as $canKey => $identifier) {
$permission = Permission::where('identifier', $identifier)->first();
if ($user) {
// 사용자의 역할별 권한을 한 번만 로드 (identifier + type 쌍으로 메모리 체크)
$userPermissions = $user->roles()
->with('permissions')
->get()
->pluck('permissions')
->flatten()
->map(fn ($p) => $p->identifier . '|' . ($p->type instanceof \BackedEnum ? $p->type->value : $p->type))
->unique()
->toArray();
if (! $permission) {
continue;
foreach ($permissionMap as $canKey => $identifier) {
$permission = $permissions->get($identifier);
if (! $permission) {
continue;
}
$typeValue = $permission->type instanceof \BackedEnum ? $permission->type->value : $permission->type;
$result[$canKey] = in_array($permission->identifier . '|' . $typeValue, $userPermissions);
}
} else {
foreach ($permissionMap as $canKey => $identifier) {
$permission = $permissions->get($identifier);
if (! $permission) {
continue;
}
$result[$canKey] = $user
? $user->hasPermission($permission->identifier, $permission->type)
: $this->checkGuestPermission($permission);
$result[$canKey] = $this->checkGuestPermission($permission);
}
}
return $result;
@@ -117,13 +117,21 @@ class CommentResource extends BaseApiResource
/**
* 현재 로그인 사용자가 이 댓글을 이미 신고했는지 반환합니다.
*
* 비로그인 또는 post.board 관계 미로드 시 false 반환.
* Controller에서 사전 로드(is_already_reported_preloaded)된 값이 있으면
* DB 쿼리 없이 반환합니다. (N+1 방지)
* 사전 로드가 없는 경우(목록 등) fallback으로 개별 쿼리를 실행합니다.
*
* @param Request $request HTTP 요청
* @return bool 이미 신고 여부
*/
private function getIsAlreadyReported(Request $request): bool
{
// 사전 로드된 값이 있으면 DB 쿼리 없이 반환 (N+1 방지)
if (isset($this->resource->is_already_reported_preloaded)) {
return $this->resource->is_already_reported_preloaded;
}
// fallback: 개별 쿼리 (목록 등 사전 로드 미적용 경로)
$user = $request->user();
$boardId = $this->post?->board?->id ?? null;
@@ -3,9 +3,8 @@
namespace Modules\Sirsoft\Board\Http\Resources;
use App\Enums\PermissionType;
use App\Http\Resources\Traits\HasRowNumber;
use App\Http\Resources\BaseApiCollection;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
/**
@@ -14,10 +13,9 @@ use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
* 게시글 목록을 페이지네이션과 함께 반환합니다.
* 순번, 공지사항, 답글 표시를 지원합니다.
*/
class PostCollection extends ResourceCollection
class PostCollection extends BaseApiCollection
{
use ChecksBoardPermission;
use HasRowNumber;
/**
* 전체 일반 게시글(원글) 수
@@ -198,13 +196,14 @@ class PostCollection extends ResourceCollection
return [
'total' => $total,
'all_total' => $this->total(),
'all_total' => $total,
'count' => $currentPageItemCount,
'per_page' => $perPage,
'current_page' => $currentPage,
'last_page' => $lastPage,
'from' => $from,
'to' => $to,
'has_more_pages' => $this->hasMorePages(),
];
}
@@ -22,7 +22,7 @@ class PostResource extends BaseApiResource
use FormatsBoardDate;
/**
* 리소스를 배열로 변환합니다.
* 리소스를 배열로 변환합니다. (상세 페이지용)
*
* @param Request $request HTTP 요청
* @return array<string, mixed> 변환된 배열 데이터
@@ -32,71 +32,37 @@ class PostResource extends BaseApiResource
$slug = $this->getSlug($request);
return [
'id' => $this->id,
'category' => $this->category,
...$this->getCommonFields($request, $slug),
// 상세 전용: 필터링된 제목/내용 (삭제글·비밀글 권한 체크)
'title' => $this->getFilteredTitle($slug),
'content' => $this->getFilteredContent($request, $slug),
// 작성자 정보
// 상세 전용: 작성자 UUID
'user_id' => $this->user?->uuid,
'author' => $this->getAuthorInfo(includeIsGuest: true),
// 게시글 속성
'is_notice' => (bool) $this->is_notice,
'is_secret' => (bool) $this->is_secret,
'content_mode' => $this->content_mode ?? 'text',
'is_new' => $this->isNew(),
'status' => $this->status?->value,
'status_label' => $this->status?->label(),
// 상세 전용: 트리거 타입
'trigger_type' => $this->trigger_type,
// 통계
'view_count' => $this->view_count,
'comment_count' => (int) ($this->comments_count ?? 0),
'reply_count' => (int) ($this->replies_count ?? 0),
'has_attachment' => ((int) ($this->attachments_count ?? 0)) > 0,
// 썸네일 이미지 (첫 번째 이미지 첨부파일)
'thumbnail' => $this->relationLoaded('attachments') ? $this->getThumbnailUrl() : null,
// 계층 구조
'parent_id' => $this->parent_id,
'depth' => $this->depth,
'is_reply' => $this->parent_id !== null,
// 타임스탬프
'created_at' => $this->formatCreatedAt($this->created_at),
'created_at_formatted' => $this->formatCreatedAtFormat(
$this->created_at,
g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')
),
// 상세 전용: 타임스탬프
'updated_at' => $this->formatDateTimeStringForUser($this->updated_at),
'deleted_at' => $this->deleted_at ? $this->formatDateTimeStringForUser($this->deleted_at) : null,
// IP 주소 (admin.manage 권한 보유자만)
'ip_address' => ($slug && $this->checkBoardPermission($slug, 'admin.manage'))
? $this->ip_address
: null,
// 상세 전용: IP 주소 (admin.manage 권한 보유자만)
'ip_address' => $this->getIpAddressForResponse($slug),
// 상세 정보 (조건부 로딩)
// 상세 전용: 관계 데이터 (조건부 로딩)
'board' => $this->relationLoaded('board') ? $this->getBoardInfo() : null,
'navigation' => isset($this->navigation) ? $this->navigation : null,
'navigation' => $this->navigation ?? null,
'parent' => $this->relationLoaded('parent') ? $this->getParentInfo() : null,
'comments' => $this->relationLoaded('comments') ? CommentResource::collection($this->comments) : null,
'attachments' => $this->relationLoaded('attachments')
? (($this->is_secret && ! $this->canViewSecretContent($request)) ? [] : AttachmentResource::collection($this->attachments))
: null,
'attachments' => $this->getAttachmentsForResponse($request, $slug),
'replies' => $this->relationLoaded('replies') ? static::collection($this->replies) : null,
// 소유권 정보
// is_author: 회원 글인 경우만 본인 여부 체크 (비회원 글은 세션 검증 불가하므로 항상 false)
'is_author' => $this->user_id !== null && $request->user()?->id === $this->user_id,
'is_guest_post' => $this->user_id === null,
// 신고 여부 (로그인 사용자 + board 관계 로드 시에만)
// 상세 전용: 신고 여부 (로그인 사용자 + board 관계 로드 시에만)
'is_already_reported' => $this->getIsAlreadyReported($request),
// 권한 정보 (is_owner + permissions)
// 권한 정보 (is_owner + abilities) — 상세 페이지에서만 포함
...$this->resourceMeta($request),
];
}
@@ -104,6 +70,9 @@ class PostResource extends BaseApiResource
/**
* 목록용 간략 정보를 배열로 변환합니다.
*
* 목록에서는 권한 정보(resourceMeta)를 포함하지 않습니다.
* 게시판 권한은 PostCollection의 컬렉션 레벨 abilities로 제공됩니다.
*
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로
* $this->when() 대신 삼항 연산자를 사용해야 합니다.
*
@@ -116,11 +85,83 @@ class PostResource extends BaseApiResource
$slug = $this->getSlug($request);
return [
'id' => $this->id,
...$this->getCommonFields($request, $slug),
// 목록 전용: 게시판 슬러그
'slug' => $this->board_slug ?? null,
'category' => $this->category,
// 목록 전용: 필터링 없는 제목 (목록은 권한 체크 불필요)
'title' => $this->title,
// 목록 전용: 타임스탬프
'deleted_at' => $this->deleted_at ? $this->formatDateTimeStringForUser($this->deleted_at) : null,
// 목록 전용: 본문 요약 (DB SUBSTRING 우선, 없으면 content에서 추출)
'content_preview' => $this->getContentPreviewForList(150),
];
}
/**
* 폼(글쓰기/글수정)용 경량 배열을 반환합니다.
*
* toArray()와 달리 권한 체크(resolveAbilities, IP, 신고 여부)를 생략하여
* 불필요한 DB 쿼리를 방지합니다. 폼에서 필요한 필드만 포함합니다.
*
* @param Request|null $request HTTP 요청
* @return array<string, mixed> 변환된 배열 데이터
*/
public function toFormArray(?Request $request = null): array
{
$request = $request ?? request();
$slug = $this->getSlug($request);
return [
'id' => $this->id,
'title' => $this->title,
'content' => $this->content,
'content_mode' => $this->content_mode ?? 'text',
'category' => $this->category,
'is_notice' => (bool) $this->is_notice,
'is_secret' => (bool) $this->is_secret,
'parent_id' => $this->parent_id,
// 작성자 정보
'author' => $this->getAuthorInfo(includeIsGuest: true),
// 타임스탬프
'created_at' => $this->formatCreatedAt($this->created_at),
'created_at_formatted' => $this->formatCreatedAtFormat(
$this->created_at,
g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')
),
// 첨부파일 (권한 체크 없이 직접 반환 — 폼 진입 시 이미 본인/관리자 검증 완료)
'attachments' => $this->relationLoaded('attachments')
? AttachmentResource::collection($this->attachments)
: null,
// 원글 정보 (답변글 수정 시)
'parent' => $this->relationLoaded('parent') ? $this->getParentInfoForForm() : null,
];
}
// =========================================================================
// 공통 필드 추출
// =========================================================================
/**
* toArray()와 toListArray()에서 공유하는 공통 필드를 반환합니다.
*
* @param Request $request HTTP 요청
* @param string|null $slug 게시판 슬러그
* @return array<string, mixed> 공통 필드 데이터
*/
private function getCommonFields(Request $request, ?string $slug): array
{
return [
'id' => $this->id,
'category' => $this->category,
// 작성자 정보
'author' => $this->getAuthorInfo(includeIsGuest: true),
@@ -138,8 +179,8 @@ class PostResource extends BaseApiResource
'reply_count' => (int) ($this->replies_count ?? 0),
'has_attachment' => ((int) ($this->attachments_count ?? 0)) > 0,
// 썸네일 이미지
'thumbnail' => $this->relationLoaded('attachments') ? $this->getThumbnailUrl() : null,
// 썸네일 이미지 (첫 번째 이미지 첨부파일)
'thumbnail' => $this->getThumbnailUrlFromRelations(),
// 계층 구조
'parent_id' => $this->parent_id,
@@ -152,23 +193,16 @@ class PostResource extends BaseApiResource
$this->created_at,
g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')
),
'deleted_at' => $this->deleted_at ? $this->formatDateTimeStringForUser($this->deleted_at) : null,
// 본문 요약 (html 모드: strip_tags → 평문 앞 150자, text 모드: 그대로 앞 150자)
'content_preview' => $this->getContentPreview(150),
// 소유권 정보
// is_author: 회원 글인 경우만 본인 여부 체크 (비회원 글은 세션 검증 불가하므로 항상 false)
'is_author' => $this->user_id !== null && $request->user()?->id === $this->user_id,
'is_guest_post' => $this->user_id === null,
// 권한 정보 (is_owner + permissions)
...$this->resourceMeta($request),
];
}
// =========================================================================
// 헬퍼 메서드 - 공통 데이터 추출
// 헬퍼 메서드 - 데이터 추출
// =========================================================================
/**
@@ -179,7 +213,8 @@ class PostResource extends BaseApiResource
*/
private function getSlug(Request $request): ?string
{
return $this->board?->slug ?? $request->route('slug');
// route slug 우선 → board relation이 미로딩 시 lazy loading 방지
return $request->route('slug') ?? ($this->relationLoaded('board') ? $this->board?->slug : null);
}
/**
@@ -243,6 +278,36 @@ class PostResource extends BaseApiResource
return $firstImage?->preview_url ?? null;
}
/**
* 썸네일 URL을 관계 로딩 상태에 따라 반환합니다.
*
* thumbnailAttachment(경량 hasOne) 우선, attachments(전체) fallback.
* 둘 다 미로딩 시 null 반환 (lazy loading 방지).
*
* @return string|null 썸네일 URL
*/
private function getThumbnailUrlFromRelations(): ?string
{
// 목록용 경량 관계 우선 — slug를 직접 전달하여 Board::find() N+1 방지
if ($this->relationLoaded('thumbnailAttachment') && $this->thumbnailAttachment) {
$attachment = $this->thumbnailAttachment;
$slug = request()->route('slug') ?? ($this->relationLoaded('board') ? $this->board?->slug : null);
if ($slug && $attachment->hash) {
return '/api/modules/sirsoft-board/boards/' . $slug . '/attachment/' . $attachment->hash . '/preview';
}
return null;
}
// 상세 페이지 등에서 attachments가 로딩된 경우 fallback
if ($this->relationLoaded('attachments')) {
return $this->getThumbnailUrl();
}
return null;
}
/**
* 게시판 정보 배열을 반환합니다.
*
@@ -286,6 +351,25 @@ class PostResource extends BaseApiResource
return $parentData;
}
/**
* 폼용 원글 정보를 반환합니다.
*
* getParentInfo()와 달리 toFormArray()를 사용하여
* 불필요한 권한 체크(resolveAbilities 등)를 생략합니다.
*
* @return array|null 원글 정보 또는 null
*/
private function getParentInfoForForm(): ?array
{
if (! $this->parent) {
return null;
}
$parentResource = new static($this->parent);
return $parentResource->toFormArray();
}
/**
* 본문 요약 텍스트를 반환합니다.
*
@@ -311,6 +395,34 @@ class PostResource extends BaseApiResource
return $preview . (mb_strlen($plain) > $length ? '...' : '');
}
/**
* 목록용 본문 요약을 반환합니다.
*
* DB SUBSTRING으로 가져온 content_preview_raw가 있으면 우선 사용하고,
* 없으면 content에서 추출합니다. (상세 페이지 등 content가 로딩된 경우)
*
* @param int $length 최대 길이
* @return string 본문 요약 텍스트
*/
private function getContentPreviewForList(int $length = 150): string
{
// DB SUBSTRING으로 가져온 raw가 있으면 우선 사용
$raw = $this->content_preview_raw ?? null;
if ($raw !== null) {
$mode = $this->content_mode ?? 'text';
$plain = $mode === 'html'
? trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($raw))))
: trim(preg_replace('/\s+/', ' ', $raw));
$preview = mb_substr($plain, 0, $length);
return $preview . (mb_strlen($plain) > $length ? '...' : '');
}
// fallback: content 전체가 로딩된 경우 (상세 페이지 등)
return $this->getContentPreview($length);
}
/**
* 현재 로그인 사용자가 이 게시글을 이미 신고했는지 반환합니다.
*
@@ -330,6 +442,45 @@ class PostResource extends BaseApiResource
->hasUserReported($user->id, $this->board->id, 'post', $this->id);
}
// =========================================================================
// 상세 페이지 전용: 조건부 필드 메서드
// =========================================================================
/**
* IP 주소를 권한에 따라 반환합니다. (admin.manage 권한 보유자만)
*
* @param string|null $slug 게시판 슬러그
* @return string|null IP 주소
*/
private function getIpAddressForResponse(?string $slug): ?string
{
if (! $slug || ! $this->checkBoardPermission($slug, 'admin.manage')) {
return null;
}
return $this->ip_address;
}
/**
* 첨부파일을 권한에 따라 반환합니다. (비밀글 열람 권한 체크)
*
* @param Request $request HTTP 요청
* @param string|null $slug 게시판 슬러그
* @return mixed 첨부파일 리소스 컬렉션 또는 null
*/
private function getAttachmentsForResponse(Request $request, ?string $slug): mixed
{
if (! $this->relationLoaded('attachments')) {
return null;
}
if ($this->is_secret && ! $this->canViewSecretContent($request, $slug)) {
return [];
}
return AttachmentResource::collection($this->attachments);
}
// =========================================================================
// 권한 관련 메서드
// =========================================================================
@@ -350,6 +501,9 @@ class PostResource extends BaseApiResource
* Admin/User 페이지별로 동일한 키를 사용하되,
* 실제 체크하는 permission identifier는 컨텍스트에 따라 다릅니다.
*
* 참고: 목록에서는 이 메서드가 호출되지 않습니다.
* 목록의 권한은 PostCollection::getBoardPermissions()에서 컬렉션 레벨로 제공됩니다.
*
* @param Request $request HTTP 요청
* @return array<string, bool> 통합 권한 정보
*/
@@ -409,58 +563,46 @@ class PostResource extends BaseApiResource
/**
* 비밀글 내용 열람 가능 여부를 확인합니다.
*
* 열람 가능 조건:
* 열람 가능 조건 (우선순위 순):
* 1. 작성자 본인 (회원 게시글)
* 2. 비밀번호 검증 완료 (회원/비회원 게시글, password_verified 플래그)
* 3. 게시판별 비밀글 읽기 권한 (posts.read-secret)
* 4. 게시판 관리자 권한 (Admin: admin.manage / User: manager)
*
* @param Request $request HTTP 요청
* @param string|null $slug 게시판 슬러그 (getSlug 중복 호출 방지)
* @return bool 열람 가능 여부
*/
private function canViewSecretContent(Request $request): bool
private function canViewSecretContent(Request $request, ?string $slug = null): bool
{
$slug = $this->getSlug($request);
// 1. 작성자 본인 (회원 게시글)
$user = Auth::user();
if ($user && $this->user_id && $this->user_id === $user->id) {
return true;
}
// 2. 비밀번호 검증 완료 (회원/비회원 게시글 모두 지원)
// 2. 비밀번호 검증 완료
if ($this->password_verified === true) {
return true;
}
// 3. 게시판별 비밀글 읽기 권한 + 4. 게시판 관리자 권한
if ($slug) {
if ($this->isAdminRequest($request)) {
// 3. 비밀글 읽기 권한 (Admin: admin.posts.read-secret)
if ($this->checkBoardPermission($slug, 'admin.posts.read-secret')) {
return true;
}
// 4. 게시판 관리자 권한 (Admin: admin.manage)
if ($this->checkBoardPermission($slug, 'admin.manage')) {
return true;
}
} else {
// 3. 비밀글 읽기 권한 (User: posts.read-secret)
if ($this->checkBoardPermission($slug, 'posts.read-secret', PermissionType::User)) {
return true;
}
// 4. 게시판 관리자 권한 (User: manager)
if ($this->checkBoardPermission($slug, 'manager', PermissionType::User)) {
return true;
}
}
// 3-4. 게시판별 권한 체크
$slug = $slug ?? $this->getSlug($request);
if (! $slug) {
return false;
}
return false;
if ($this->isAdminRequest($request)) {
return $this->checkBoardPermission($slug, 'admin.posts.read-secret')
|| $this->checkBoardPermission($slug, 'admin.manage');
}
return $this->checkBoardPermission($slug, 'posts.read-secret', PermissionType::User)
|| $this->checkBoardPermission($slug, 'manager', PermissionType::User);
}
// =========================================================================
// 콘텐츠 필터링 메서드
// 콘텐츠 필터링 메서드 (상세 페이지 전용)
// =========================================================================
/**
@@ -500,11 +642,10 @@ class PostResource extends BaseApiResource
// 비밀글: 권한 없으면 content를 null로 반환
// - is_secret = true인 경우에만 검증 필요
// - 일반글(is_secret = false)은 비밀번호 유무와 관계없이 공개
if ($this->is_secret && ! $this->canViewSecretContent($request)) {
if ($this->is_secret && ! $this->canViewSecretContent($request, $slug)) {
return null;
}
return $this->content;
}
}
@@ -3,7 +3,7 @@
namespace Modules\Sirsoft\Board\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use App\Http\Resources\BaseApiCollection;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
/**
@@ -11,7 +11,7 @@ use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
*
* 신고 목록을 페이지네이션과 함께 반환합니다.
*/
class ReportCollection extends ResourceCollection
class ReportCollection extends BaseApiCollection
{
use ChecksBoardPermission;
@@ -129,8 +129,12 @@ class BoardActivityLogListener implements HookListenerInterface
*
* @param Board $board 삭제된 게시판
*/
public function handleBoardAfterDelete(Board $board): void
public function handleBoardAfterDelete(?Board $board): void
{
if ($board === null) {
return;
}
$this->logActivity('board.delete', [
'loggable' => $board,
'description_key' => 'sirsoft-board::activity_log.description.board_delete',
@@ -217,8 +221,12 @@ class BoardActivityLogListener implements HookListenerInterface
*
* @param BoardType $boardType 삭제된 게시판 유형
*/
public function handleBoardTypeAfterDelete(BoardType $boardType): void
public function handleBoardTypeAfterDelete(?BoardType $boardType): void
{
if ($boardType === null) {
return;
}
$this->logActivity('board_type.delete', [
'loggable' => $boardType,
'description_key' => 'sirsoft-board::activity_log.description.board_type_delete',
@@ -282,8 +290,12 @@ class BoardActivityLogListener implements HookListenerInterface
* @param Post $deletedPost 삭제된 게시글
* @param string $slug 게시판 슬러그
*/
public function handlePostAfterDelete(Post $deletedPost, string $slug): void
public function handlePostAfterDelete(?Post $deletedPost, string $slug): void
{
if ($deletedPost === null) {
return;
}
$deletedPost->loadMissing('board');
$this->logActivity('post.delete', [
@@ -389,8 +401,12 @@ class BoardActivityLogListener implements HookListenerInterface
* @param Comment $deletedComment 삭제된 댓글
* @param string $slug 게시판 슬러그
*/
public function handleCommentAfterDelete(Comment $deletedComment, string $slug): void
public function handleCommentAfterDelete(?Comment $deletedComment, string $slug): void
{
if ($deletedComment === null) {
return;
}
$this->logActivity('comment.delete', [
'loggable' => $deletedComment,
'description_key' => 'sirsoft-board::activity_log.description.comment_delete',
@@ -440,8 +456,12 @@ class BoardActivityLogListener implements HookListenerInterface
*
* @param Attachment $attachment 업로드된 첨부파일
*/
public function handleAttachmentAfterUpload(Attachment $attachment): void
public function handleAttachmentAfterUpload(?Attachment $attachment): void
{
if ($attachment === null) {
return; // 큐 워커 시점에 모델이 이미 사라진 경우 스킵
}
$this->logActivity('attachment.upload', [
'loggable' => $attachment,
'description_key' => 'sirsoft-board::activity_log.description.board_attachment_upload',
@@ -459,8 +479,12 @@ class BoardActivityLogListener implements HookListenerInterface
*
* @param Attachment $attachment 삭제된 첨부파일
*/
public function handleAttachmentAfterDelete(Attachment $attachment): void
public function handleAttachmentAfterDelete(?Attachment $attachment): void
{
if ($attachment === null) {
return;
}
$this->logActivity('attachment.delete', [
'loggable' => $attachment,
'description_key' => 'sirsoft-board::activity_log.description.board_attachment_delete',
@@ -481,8 +505,12 @@ class BoardActivityLogListener implements HookListenerInterface
*
* @param Report $report 생성된 신고
*/
public function handleReportAfterCreate(Report $report): void
public function handleReportAfterCreate(?Report $report): void
{
if ($report === null) {
return;
}
$this->logActivity('report.create', [
'loggable' => $report,
'description_key' => 'sirsoft-board::activity_log.description.report_create',
@@ -496,8 +524,12 @@ class BoardActivityLogListener implements HookListenerInterface
*
* @param Report $updatedReport 상태 변경된 신고
*/
public function handleReportAfterUpdateStatus(Report $updatedReport): void
public function handleReportAfterUpdateStatus(?Report $updatedReport): void
{
if ($updatedReport === null) {
return;
}
$this->logActivity('report.update_status', [
'loggable' => $updatedReport,
'description_key' => 'sirsoft-board::activity_log.description.report_update_status',
@@ -542,8 +574,12 @@ class BoardActivityLogListener implements HookListenerInterface
*
* @param Report $report 삭제된 신고
*/
public function handleReportAfterDelete(Report $report): void
public function handleReportAfterDelete(?Report $report): void
{
if ($report === null) {
return;
}
$this->logActivity('report.delete', [
'loggable' => $report,
'description_key' => 'sirsoft-board::activity_log.description.report_delete',
@@ -0,0 +1,51 @@
<?php
namespace Modules\Sirsoft\Board\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Models\Comment;
/**
* 댓글 생성/삭제/복원 시 게시판의 comments_count를 재카운팅합니다.
*/
class BoardCommentsCountSyncListener implements HookListenerInterface
{
/**
* 구독할 훅 목록을 반환합니다.
*
* @return array 훅 구독 설정
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.comment.after_create' => ['method' => 'syncCommentsCount', 'priority' => 10],
'sirsoft-board.comment.after_delete' => ['method' => 'syncCommentsCount', 'priority' => 10],
'sirsoft-board.comment.after_restore' => ['method' => 'syncCommentsCount', 'priority' => 10],
];
}
/**
* @inheritDoc
*/
public function handle(...$args): void {}
/**
* 게시판의 comments_count를 재카운팅합니다.
*
* @param Comment $comment 댓글 모델
* @param string $slug 게시판 slug
* @return void
*/
public function syncCommentsCount(Comment $comment, string $slug): void
{
$count = DB::table('board_comments')
->where('board_id', $comment->board_id)
->whereNull('deleted_at')
->count();
DB::table('boards')
->where('id', $comment->board_id)
->update(['comments_count' => $count]);
}
}
@@ -8,13 +8,7 @@ use App\Contracts\Extension\HookListenerInterface;
* 알림 채널 설정 필터 리스너
*
* BaseNotification::via()에서 발행하는 sirsoft-board.notification.channels
* 필터 훅을 수신하여, 환경설정에 저장된 채널 설정을 적용합니다.
*
* 알림 타입 매핑:
* - new_post_admin → notify_admin_on_post_channels
* - new_comment, post_reply, reply_comment, post_action → notify_author_channels
* - report_action → notify_author_on_report_action_channels
* - report_received_admin → notify_admin_on_report_channels
* 필터 훅을 수신하여, notifications.channels 환경설정을 적용합니다.
*/
class BoardNotificationChannelListener implements HookListenerInterface
{
@@ -40,26 +34,27 @@ class BoardNotificationChannelListener implements HookListenerInterface
public function handle(...$args): void {}
/**
* 알림 타입별 채널 설정을 적용합니다.
* notifications.channels 설정 기반으로 활성화된 채널만 반환합니다.
*
* @param array<string> $channels 기본 채널 배열 (['mail'])
* @param string $type 알림 타입 (new_post_admin, new_comment 등), 빈 문자열이면 기본 채널 반환
* @param array<string> $channels 기본 채널 배열
* @param string $type 알림 타입 (미사용, 향후 확장 대비)
* @param object|null $notifiable 수신자
* @return array<string> 적용된 채널 배열
* @return array<string> 활성화된 채널 배열
*/
public function filterChannels(array $channels, string $type = '', ?object $notifiable = null): array
{
$channelSettings = g7_module_settings('sirsoft-board', 'basic_defaults', []);
$notifSettings = g7_module_settings('sirsoft-board', 'notifications', []);
$channelSettings = $notifSettings['channels'] ?? [];
$reportPolicy = g7_module_settings('sirsoft-board', 'report_policy', []);
if (empty($channelSettings)) {
return $channels;
}
return match ($type) {
'new_post_admin' => $channelSettings['notify_admin_on_post_channels'] ?? $channels,
'new_comment', 'post_reply', 'reply_comment', 'post_action'
=> $channelSettings['notify_author_channels'] ?? $channels,
'report_received_admin' => $reportPolicy['notify_admin_on_report_channels'] ?? $channels,
'report_action' => $reportPolicy['notify_author_on_report_action_channels'] ?? $channels,
default => $channels,
};
$activeChannelIds = collect($channelSettings)
->where('is_active', true)
->pluck('id')
->toArray();
return array_values(array_filter($channels, fn ($ch) => in_array($ch, $activeChannelIds)));
}
}
@@ -0,0 +1,692 @@
<?php
namespace Modules\Sirsoft\Board\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use BackedEnum;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Enums\TriggerType;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Services\UserNotificationSettingService;
/**
* 게시판 알림 데이터 필터 리스너
*
* notification_definitions의 extract_data 필터를 처리하여
* 알림 발송에 필요한 데이터와 컨텍스트를 제공합니다.
* 수신자 결정은 notification_templates.recipients 설정에 위임합니다.
*/
class BoardNotificationDataListener implements HookListenerInterface
{
public function __construct(
private readonly BoardRepositoryInterface $boardRepository,
private readonly PostRepositoryInterface $postRepository,
private readonly CommentRepositoryInterface $commentRepository,
private readonly UserNotificationSettingService $userNotificationSettingService,
) {}
/**
* 구독할 훅 목록을 반환합니다.
*
* @return array
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.notification.extract_data' => [
'method' => 'extractData',
'priority' => 20,
'type' => 'filter',
],
'core.notification.filter_default_definitions' => [
'method' => 'contributeDefaultDefinitions',
'priority' => 20,
'type' => 'filter',
],
];
}
/**
* 훅 이벤트를 처리합니다.
*
* @param mixed ...$args 훅에서 전달된 인수들
* @return void
*/
public function handle(...$args): void {}
/**
* 게시판 모듈의 기본 알림 정의를 코어 리셋 로직에 제공합니다.
*
* @param array $definitions 현재까지 수집된 기본 정의 목록
* @param array $context type/channel 필터 컨텍스트
* @return array 게시판 시더 정의를 병합한 목록
*/
public function contributeDefaultDefinitions(array $definitions, array $context = []): array
{
$seeder = new \Modules\Sirsoft\Board\Database\Seeders\BoardNotificationDefinitionSeeder();
return array_merge($definitions, $seeder->getDefaultDefinitions());
}
/**
* 알림 유형에 따라 데이터와 컨텍스트를 추출합니다.
*
* @param array $default 기본 extract_data 구조
* @param string $type 알림 정의 유형
* @param array $args 훅에서 전달된 원본 인수
* @return array{notifiable: null, notifiables: null, data: array, context: array}
*/
public function extractData(array $default, string $type, array $args): array
{
return match ($type) {
'new_comment' => $this->extractNewComment($args),
'reply_comment' => $this->extractReplyComment($args),
'post_reply' => $this->extractPostReply($args),
'post_action' => $this->extractPostAction($args),
'report_action' => $this->extractReportAction($args),
'new_post_admin' => $this->extractNewPostAdmin($args),
'report_received_admin' => $this->extractReportReceivedAdmin($args),
default => $default,
};
}
// ──────────────────────────────────────────────
// 댓글/대댓글 알림 (2종)
// ──────────────────────────────────────────────
/**
* 새 댓글 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$comment, $slug]
* @return array
*/
private function extractNewComment(array $args): array
{
$comment = $args[0] ?? null;
$slug = $args[1] ?? '';
if (! $comment instanceof Comment || ! empty($comment->parent_id)) {
return $this->emptyResult();
}
$board = $this->boardRepository->findBySlug($slug);
if (! $board || ! $board->notify_author) {
return $this->emptyResult();
}
$post = $this->postRepository->find($slug, $comment->post_id);
if (! $post || ! $post->user_id) {
return $this->emptyResult();
}
// 사용자 개인 알림 설정 체크
if (! $this->isUserNotificationEnabled($post->user_id, 'notify_comment')) {
return $this->emptyResult();
}
$commentContent = $this->truncateContent($comment->content);
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $board->localizedName ?? $board->name,
'post_title' => $post->title ?? '',
'comment_author' => $comment->user?->name ?? $comment->author_name ?? '',
'comment_content' => $commentContent,
'post_url' => $this->buildPostUrl($board, $post),
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => $comment->user_id,
'trigger_user' => $comment->user,
'related_users' => [
'post_author' => $post->user,
],
],
];
}
/**
* 대댓글 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$comment, $slug]
* @return array
*/
private function extractReplyComment(array $args): array
{
$comment = $args[0] ?? null;
$slug = $args[1] ?? '';
if (! $comment instanceof Comment || empty($comment->parent_id)) {
return $this->emptyResult();
}
$board = $this->boardRepository->findBySlug($slug);
if (! $board || ! $board->notify_author) {
return $this->emptyResult();
}
$post = $this->postRepository->find($slug, $comment->post_id);
if (! $post) {
return $this->emptyResult();
}
$parentComment = $this->commentRepository->find($slug, $comment->parent_id);
if (! $parentComment || ! $parentComment->user_id) {
return $this->emptyResult();
}
// 사용자 개인 알림 설정 체크
if (! $this->isUserNotificationEnabled($parentComment->user_id, 'notify_reply_comment')) {
return $this->emptyResult();
}
$commentContent = $this->truncateContent($comment->content);
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $board->localizedName ?? $board->name,
'post_title' => $post->title ?? '',
'comment_author' => $comment->user?->name ?? $comment->author_name ?? '',
'comment_content' => $commentContent,
'post_url' => $this->buildPostUrl($board, $post),
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => $comment->user_id,
'trigger_user' => $comment->user,
'related_users' => [
'parent_comment_author' => $parentComment->user,
],
],
];
}
// ──────────────────────────────────────────────
// 게시글 알림 (3종)
// ──────────────────────────────────────────────
/**
* 답변글 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$post, $slug, $options?]
* @return array
*/
private function extractPostReply(array $args): array
{
$post = $args[0] ?? null;
$slug = $args[1] ?? '';
if (! $post instanceof Post || empty($post->parent_id)) {
return $this->emptyResult();
}
$board = $this->boardRepository->findBySlug($slug);
if (! $board || ! $board->notify_author) {
return $this->emptyResult();
}
$parentPost = $this->postRepository->find($slug, $post->parent_id);
if (! $parentPost || ! $parentPost->user_id) {
return $this->emptyResult();
}
// 사용자 개인 알림 설정 체크
if (! $this->isUserNotificationEnabled($parentPost->user_id, 'notify_post_reply')) {
return $this->emptyResult();
}
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $board->localizedName ?? $board->name,
'post_title' => $parentPost->title ?? '',
'post_url' => $this->buildPostUrl($board, $parentPost),
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => $post->user_id,
'trigger_user' => $post->user,
'related_users' => [
'original_post_author' => $parentPost->user,
],
],
];
}
/**
* 관리자 처리(직권) 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$post, $slug] 또는 [$comment, $slug]
* @return array
*/
private function extractPostAction(array $args): array
{
$target = $args[0] ?? null;
$slug = $args[1] ?? '';
$board = $this->boardRepository->findBySlug($slug);
if (! $board || ! $board->notify_author) {
return $this->emptyResult();
}
if ($target instanceof Post) {
if (! $target->user_id) {
return $this->emptyResult();
}
// 신고 기반 처리(수동/자동)는 report_action에서 처리 (admin 직권만 여기서 처리)
if (in_array($target->trigger_type, [TriggerType::Report, TriggerType::AutoHide], true)) {
return $this->emptyResult();
}
$actionType = $this->resolveActionType($args);
$targetTypeLabel = __('sirsoft-board::notification.report_action.target_types.post');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $board->localizedName ?? $board->name,
'post_title' => $target->title ?? '',
'action_type' => $actionType,
'target_type' => $targetTypeLabel,
'post_url' => $this->buildPostUrl($board, $target),
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => Auth::id(),
'trigger_user' => Auth::user(),
'related_users' => [
'post_author' => $target->user,
],
],
];
}
if ($target instanceof Comment) {
if (! $target->user_id) {
return $this->emptyResult();
}
// 신고 기반 처리(수동/자동)는 report_action에서 처리 (admin 직권만 여기서 처리)
if (in_array($target->trigger_type, [TriggerType::Report, TriggerType::AutoHide], true)) {
return $this->emptyResult();
}
$post = $this->postRepository->find($slug, $target->post_id);
$actionType = $this->resolveActionType($args);
$targetTypeLabel = __('sirsoft-board::notification.report_action.target_types.comment');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $board->localizedName ?? $board->name,
'post_title' => $post?->title ?? '',
'action_type' => $actionType,
'target_type' => $targetTypeLabel,
'post_url' => $post ? $this->buildPostUrl($board, $post) : config('app.url'),
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => Auth::id(),
'trigger_user' => Auth::user(),
'related_users' => [
'post_author' => $target->user,
],
],
];
}
return $this->emptyResult();
}
/**
* 신고 처리 결과 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$post, $slug] 또는 [$comment, $slug]
* @return array
*/
private function extractReportAction(array $args): array
{
$target = $args[0] ?? null;
$slug = $args[1] ?? '';
$reportPolicy = g7_module_settings('sirsoft-board', 'report_policy', []);
if (empty($reportPolicy['notify_author_on_report_action'])) {
return $this->emptyResult();
}
$board = $this->boardRepository->findBySlug($slug);
if (! $board) {
return $this->emptyResult();
}
if ($target instanceof Post) {
// 신고 기반 처리(수동/자동)만 처리 (admin 직권은 post_action에서 처리)
if (! $target->user_id || ! in_array($target->trigger_type, [TriggerType::Report, TriggerType::AutoHide], true)) {
return $this->emptyResult();
}
$actionType = $this->resolveActionType($args);
$targetTypeLabel = __('sirsoft-board::notification.report_action.target_types.post');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $board->localizedName ?? $board->name,
'post_title' => $target->title ?? '',
'action_type' => $actionType,
'target_type' => $targetTypeLabel,
'post_url' => $this->buildPostUrl($board, $target),
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => Auth::id(),
'trigger_user' => Auth::user(),
'related_users' => [
'post_author' => $target->user,
],
],
];
}
if ($target instanceof Comment) {
// 신고 기반 처리(수동/자동)만 처리 (admin 직권은 post_action에서 처리)
if (! $target->user_id || ! in_array($target->trigger_type, [TriggerType::Report, TriggerType::AutoHide], true)) {
return $this->emptyResult();
}
$post = $this->postRepository->find($slug, $target->post_id);
$actionType = $this->resolveActionType($args);
$targetTypeLabel = __('sirsoft-board::notification.report_action.target_types.comment');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $board->localizedName ?? $board->name,
'post_title' => $post?->title ?? '',
'action_type' => $actionType,
'target_type' => $targetTypeLabel,
'post_url' => $post ? $this->buildPostUrl($board, $post) : config('app.url'),
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => Auth::id(),
'trigger_user' => Auth::user(),
'related_users' => [
'post_author' => $target->user,
],
],
];
}
return $this->emptyResult();
}
// ──────────────────────────────────────────────
// 관리자 알림 (2종)
// ──────────────────────────────────────────────
/**
* 신규 게시글 관리자 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$post, $slug, $options?]
* @return array
*/
private function extractNewPostAdmin(array $args): array
{
$post = $args[0] ?? null;
$slug = $args[1] ?? '';
$options = $args[2] ?? [];
if (! $post instanceof Post) {
return $this->emptyResult();
}
// skip_notification 옵션 확인
if (! empty($options['skip_notification'])) {
return $this->emptyResult();
}
$board = $this->boardRepository->findBySlug($slug);
if (! $board || ! $board->notify_admin_on_post) {
return $this->emptyResult();
}
// 답변글은 제외
if (! empty($post->parent_id)) {
return $this->emptyResult();
}
// 게시판별 관리자 역할 조회, 없으면 superAdmin 폴백
$managerRole = Role::where('identifier', "sirsoft-board.{$slug}.manager")->first();
$managers = $managerRole ? $managerRole->users()->get() : collect();
if ($managers->isEmpty()) {
$superAdmin = User::superAdmins()->first();
$managers = $superAdmin ? collect([$superAdmin]) : collect();
}
if ($managers->isEmpty()) {
return $this->emptyResult();
}
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $board->localizedName ?? $board->name,
'post_title' => $post->title ?? '',
'post_author' => $post->author_name ?? $post->user?->name ?? '',
'post_url' => $this->buildPostUrl($board, $post),
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => $post->user_id,
'trigger_user' => $post->user,
'related_users' => [
'board_managers' => $managers,
],
],
];
}
/**
* 신고 접수 관리자 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$report]
* @return array
*/
private function extractReportReceivedAdmin(array $args): array
{
$report = $args[0] ?? null;
if (! $report instanceof Report) {
return $this->emptyResult();
}
$reportPolicy = g7_module_settings('sirsoft-board', 'report_policy', []);
if (empty($reportPolicy['notify_admin_on_report'])) {
return $this->emptyResult();
}
// 발송 범위 조건: per_case = 케이스당 1회, per_report = 신고 건마다
$scope = $reportPolicy['notify_admin_on_report_scope'] ?? 'per_case';
if ($scope === 'per_case') {
$activeCycleLogCount = $report->logs()
->when($report->last_activated_at,
fn ($q) => $q->where('created_at', '>=', $report->last_activated_at)
)->count();
if ($activeCycleLogCount > 1) {
return $this->emptyResult();
}
}
$board = $report->board;
if (! $board) {
return $this->emptyResult();
}
$report->loadMissing(['logs' => fn ($q) => $q->latest()]);
$log = $report->logs->first();
$postTitle = $log?->snapshot['title'] ?? '';
$boardName = $log?->snapshot['board_name'] ?? ($board->localizedName ?? $board->name);
$targetType = $report->target_type instanceof BackedEnum
? $report->target_type->value
: (string) $report->target_type;
$reasonType = $log?->reason_type instanceof BackedEnum
? $log->reason_type->value
: ($log?->reason_type ? (string) $log->reason_type : 'etc');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'name' => '{recipient_name}',
'app_name' => config('app.name'),
'board_name' => $boardName,
'post_title' => $postTitle,
'target_type' => __('sirsoft-board::notification.report_action.target_types.' . $targetType),
'reason_type' => __('sirsoft-board::notification.report_received_admin.reason_types.' . $reasonType),
'report_url' => config('app.url') . '/admin/boards/reports',
'site_url' => config('app.url'),
],
'context' => [
'trigger_user_id' => $report->reporter_id ?? null,
'trigger_user' => $report->reporter ?? null,
],
];
}
// ──────────────────────────────────────────────
// 헬퍼 메서드
// ──────────────────────────────────────────────
/**
* 게시글 URL을 생성합니다.
*
* @param Board $board 게시판
* @param Post $post 게시글
* @return string
*/
private function buildPostUrl(Board $board, Post $post): string
{
return config('app.url') . "/board/{$board->slug}/{$post->id}";
}
/**
* 컨텐츠를 200자로 자릅니다.
*
* @param string|null $content 원본 컨텐츠
* @return string
*/
private function truncateContent(?string $content): string
{
$text = strip_tags($content ?? '');
return mb_strlen($text) > 200 ? mb_substr($text, 0, 200) . '...' : $text;
}
/**
* 모델 상태에서 처리 유형을 추론합니다.
*
* after_blind/after_delete/after_restore 훅에서 호출되므로
* 모델의 현재 상태로 어떤 처리가 수행되었는지 판단합니다.
*
* @param array $args 훅 인수 [$target, $slug, ...]
* @return string 처리 유형 (번역된 라벨)
*/
private function resolveActionType(array $args): string
{
$target = $args[0] ?? null;
if ($target instanceof Post) {
if ($target->trashed()) {
return __('sirsoft-board::notification.post_action.action_types.deleted');
}
if ($target->status === PostStatus::Blinded) {
return __('sirsoft-board::notification.post_action.action_types.blind');
}
return __('sirsoft-board::notification.post_action.action_types.restored');
}
if ($target instanceof Comment) {
if ($target->trashed()) {
return __('sirsoft-board::notification.post_action.action_types.deleted');
}
if ($target->status === PostStatus::Blinded) {
return __('sirsoft-board::notification.post_action.action_types.blind');
}
return __('sirsoft-board::notification.post_action.action_types.restored');
}
return '';
}
/**
* 사용자의 개인 알림 설정이 활성화되어 있는지 확인합니다.
*
* 설정 레코드가 없으면 기본적으로 알림을 받는 것으로 간주합니다.
*
* @param int $userId 사용자 ID
* @param string $field 알림 설정 필드명 (notify_comment, notify_reply_comment, notify_post_reply)
* @return bool 알림 활성화 여부
*/
private function isUserNotificationEnabled(int $userId, string $field): bool
{
$settings = $this->userNotificationSettingService->getByUserId($userId);
// 설정 레코드 없음 → 기본값: 알림 미수신
if (! $settings) {
return false;
}
return (bool) ($settings->{$field} ?? false);
}
/**
* 빈 결과를 반환합니다.
*
* @return array
*/
private function emptyResult(): array
{
return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []];
}
}
@@ -1,870 +0,0 @@
<?php
namespace Modules\Sirsoft\Board\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Enums\TriggerType;
use Modules\Sirsoft\Board\Notifications\NewCommentNotification;
use Modules\Sirsoft\Board\Notifications\NewPostAdminNotification;
use Modules\Sirsoft\Board\Notifications\PostActionNotification;
use Modules\Sirsoft\Board\Notifications\PostReplyNotification;
use Modules\Sirsoft\Board\Notifications\ReplyCommentNotification;
use Modules\Sirsoft\Board\Notifications\ReportActionNotification;
use Modules\Sirsoft\Board\Notifications\ReportReceivedAdminNotification;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
use Modules\Sirsoft\Board\Services\UserNotificationSettingService;
/**
* 게시판 알림 발송 리스너
*
* 게시글/댓글 생성 및 관리자 처리 이벤트를 수신하여
* 조건에 맞는 사용자에게 이메일 알림을 발송합니다.
*
* 설정 확인 우선순위:
* 1. 게시판 레벨 설정 (Board.notify_*) — OFF이면 무조건 미발송
* 2. 사용자 레벨 설정 (UserNotificationSetting.notify_*) — OFF이면 미발송
* 3. 자기 자신 제외 — 작성자 == 수신자이면 미발송
*/
class BoardNotificationListener implements HookListenerInterface
{
/**
* 중복 실행 방지를 위한 처리 완료 키 저장소
* HookManager가 addAction + Event::listen 이중 등록으로 인해
* 동일 훅이 두 번 실행되는 문제를 방지합니다.
*
* @var array<string, bool>
*/
private static array $processedKeys = [];
/**
* BoardNotificationListener 생성자
*
* @param UserNotificationSettingService $notificationSettingService 알림 설정 서비스
* @param BoardRepositoryInterface $boardRepository 게시판 저장소
* @param PostRepositoryInterface $postRepository 게시글 저장소
* @param CommentRepositoryInterface $commentRepository 댓글 저장소
*/
public function __construct(
private UserNotificationSettingService $notificationSettingService,
private BoardRepositoryInterface $boardRepository,
private PostRepositoryInterface $postRepository,
private CommentRepositoryInterface $commentRepository
) {}
/**
* 구독할 훅 목록을 반환합니다.
*
* @return array<string, array{method: string, priority: int}>
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-board.comment.after_create' => [
'method' => 'afterCommentCreate',
'priority' => 20,
],
'sirsoft-board.post.after_create' => [
'method' => 'afterPostCreate',
'priority' => 20,
],
'sirsoft-board.post.after_blind' => [
'method' => 'afterPostBlind',
'priority' => 20,
],
'sirsoft-board.post.after_delete' => [
'method' => 'afterPostDelete',
'priority' => 20,
],
'sirsoft-board.post.after_restore' => [
'method' => 'afterPostRestore',
'priority' => 20,
],
'sirsoft-board.comment.after_blind' => [
'method' => 'afterCommentBlind',
'priority' => 20,
],
'sirsoft-board.comment.after_delete' => [
'method' => 'afterCommentDelete',
'priority' => 20,
],
'sirsoft-board.comment.after_restore' => [
'method' => 'afterCommentRestore',
'priority' => 20,
],
'sirsoft-board.report.after_create' => [
'method' => 'afterReportCreate',
'priority' => 20,
],
];
}
/**
* 기본 핸들러 (인터페이스 요구사항)
*
* @param mixed ...$args 전달 인자
* @return void
*/
public function handle(...$args): void {}
/**
* 댓글 생성 후 알림을 처리합니다.
*
* 시나리오 1+5: 내 글에 댓글 → 글 작성자에게 알림
* 시나리오 2: 내 댓글에 대댓글 → 부모 댓글 작성자에게 알림
*
* @param Comment $comment 생성된 댓글
* @param string $slug 게시판 슬러그
* @return void
*/
public function afterCommentCreate(Comment $comment, string $slug): void
{
// 중복 실행 방지
$key = "comment_create_{$slug}_{$comment->id}";
if (isset(self::$processedKeys[$key])) {
return;
}
self::$processedKeys[$key] = true;
$board = $this->boardRepository->findBySlug($slug);
if (! $board) {
return;
}
$post = $this->postRepository->find($slug, $comment->post_id);
if (! $post) {
return;
}
$commentAuthorName = $comment->user?->name ?? $comment->author_name ?? '';
// 대댓글인 경우 대댓글 알림만 발송
if (! empty($comment->parent_id)) {
// 시나리오 2: 대댓글 알림
$this->notifyParentCommentAuthor($board, $post, $comment, $slug, $commentAuthorName);
} else {
// 시나리오 1+5: 내 글에 댓글 알림 (일반 댓글만)
$this->notifyPostAuthorOnComment($board, $post, $comment, $slug, $commentAuthorName);
}
}
/**
* 게시글 생성 후 알림을 처리합니다.
*
* 시나리오 3: 답변글 → 원글 작성자에게 알림
* 시나리오 6: 새 게시글 → 게시판 관리자에게 알림
*
* 외부 모듈이 `sirsoft-board.notification.skip_post_create` Filter 훅으로
* true를 반환하면 알림 발송을 건너뜁니다.
* (예: sirsoft-ecommerce — 이커머스 경로로 등록된 문의글은 이커머스 전용 알림으로 처리)
*
* @param Post $post 생성된 게시글
* @param string $slug 게시판 슬러그
* @param array $options 게시글 생성 옵션 (예: ['skip_notification' => true])
* @return void
*/
public function afterPostCreate(Post $post, string $slug, array $options = []): void
{
try {
// 중복 실행 방지
$key = "post_create_{$slug}_{$post->id}";
if (isset(self::$processedKeys[$key])) {
return;
}
self::$processedKeys[$key] = true;
// 알림 SKIP 여부 확인 — 호출자가 $options['skip_notification']으로 명시적으로 전달
if (($options['skip_notification'] ?? false) === true) {
return;
}
$board = $this->boardRepository->findBySlug($slug);
if (! $board) {
return;
}
// 시나리오 3: 답변글 알림
$this->notifyParentPostAuthorOnReply($board, $post, $slug);
// 시나리오 6: 관리자 새글 알림
$this->notifyAdminOnNewPost($board, $post, $slug);
} catch (\Exception $e) {
Log::error('BoardNotificationListener: 게시글 생성 후 알림 처리 실패', [
'post_id' => $post->id ?? null,
'slug' => $slug,
'error' => $e->getMessage(),
]);
}
}
/**
* 게시글 블라인드 처리 알림을 발송합니다.
*
* @param Post $post 처리된 게시글
* @param string $slug 게시판 슬러그
* @return void
*/
public function afterPostBlind(Post $post, string $slug): void
{
$this->sendPostActionNotification($post, $slug, 'blind');
}
/**
* 게시글 삭제 알림을 발송합니다.
*
* 외부 모듈이 `sirsoft-board.notification.skip_post_delete` Filter 훅으로
* true를 반환하면 알림 발송을 건너뜁니다.
* (예: sirsoft-ecommerce — 이커머스 경로로 삭제된 문의글은 알림 불필요)
*
* @param Post $post 처리된 게시글
* @param string $slug 게시판 슬러그
* @param array $options 게시글 삭제 옵션 (예: ['skip_notification' => true])
* @return void
*/
public function afterPostDelete(Post $post, string $slug, array $options = []): void
{
// 알림 SKIP 여부 확인 — 호출자가 $options['skip_notification']으로 명시적으로 전달
if (($options['skip_notification'] ?? false) === true) {
return;
}
$this->sendPostActionNotification($post, $slug, 'deleted');
}
/**
* 게시글 복원 알림을 발송합니다.
*
* @param Post $post 처리된 게시글
* @param string $slug 게시판 슬러그
* @return void
*/
public function afterPostRestore(Post $post, string $slug): void
{
$this->sendPostActionNotification($post, $slug, 'restored');
}
/**
* 게시글 처리 알림을 발송합니다.
*
* 시나리오 4: 관리자 또는 시스템이 게시글 처리 → 글 작성자에게 알림
* - TriggerType::Report / TriggerType::AutoHide → ReportActionNotification (신고 처리 알림)
* - 그 외 (Admin 직권 등) → PostActionNotification (관리자 처리 알림)
*
* @param Post $post 처리된 게시글
* @param string $slug 게시판 슬러그
* @param string $actionType 처리 유형 (blind, deleted, restored)
* @return void
*/
private function sendPostActionNotification(Post $post, string $slug, string $actionType): void
{
// 중복 실행 방지
$key = "post_action_{$actionType}_{$slug}_{$post->id}";
if (isset(self::$processedKeys[$key])) {
return;
}
self::$processedKeys[$key] = true;
if (! $post->user_id) {
return;
}
// 관리자가 처리한 경우에만 알림 발송 (본인이 직접 삭제한 경우 제외)
$currentUserId = Auth::id();
if ($currentUserId === $post->user_id) {
return;
}
$board = $this->boardRepository->findBySlug($slug);
if (! $board) {
return;
}
// 수신자 이메일 확인
$recipient = $post->user;
if (! $recipient || ! $recipient->email) {
return;
}
// 신고 처리 vs 자동 블라인드 vs 관리자 직권 처리 분기
if ($post->trigger_type === TriggerType::Report || $post->trigger_type === TriggerType::AutoHide) {
$reportPolicy = g7_module_settings('sirsoft-board', 'report_policy', []);
if (empty($reportPolicy['notify_author_on_report_action'])) {
return;
}
try {
$recipient->notify(new ReportActionNotification(
postId: $post->id,
boardId: $board->id,
slug: $slug,
postTitle: $post->title ?? '',
actionType: $actionType,
targetType: 'post'
));
Log::info('게시판 알림 발송: 신고/자동 블라인드 처리 (게시글)', [
'type' => 'report_action',
'trigger_type' => $post->trigger_type?->value,
'action_type' => $actionType,
'board' => $slug,
'post_id' => $post->id,
'recipient_id' => $post->user_id,
]);
} catch (\Exception $e) {
Log::error('게시판 알림 발송 실패: 신고/자동 블라인드 처리 (게시글)', [
'type' => 'report_action',
'action_type' => $actionType,
'board' => $slug,
'post_id' => $post->id,
'recipient_id' => $post->user_id,
'error' => $e->getMessage(),
]);
}
} else {
// 관리자 직권 처리 알림 (기존 로직)
if (! $board->notify_author) {
return;
}
$settings = $this->notificationSettingService->getByUserId($post->user_id);
if (! $settings || ! $settings->notify_post_complete) {
return;
}
try {
$recipient->notify(new PostActionNotification(
postId: $post->id,
boardId: $board->id,
slug: $slug,
postTitle: $post->title ?? '',
actionType: $actionType
));
Log::info('게시판 알림 발송: 관리자 처리', [
'type' => 'post_action',
'action_type' => $actionType,
'board' => $slug,
'post_id' => $post->id,
'recipient_id' => $post->user_id,
]);
} catch (\Exception $e) {
Log::error('게시판 알림 발송 실패: 관리자 처리', [
'type' => 'post_action',
'action_type' => $actionType,
'board' => $slug,
'post_id' => $post->id,
'recipient_id' => $post->user_id,
'error' => $e->getMessage(),
]);
}
}
}
/**
* 댓글 블라인드 처리 알림을 발송합니다.
*
* @param Comment $comment 처리된 댓글
* @param string $slug 게시판 슬러그
* @return void
*/
public function afterCommentBlind(Comment $comment, string $slug): void
{
$this->sendCommentActionNotification($comment, $slug, 'blind');
}
/**
* 댓글 삭제 알림을 발송합니다.
*
* @param Comment $comment 처리된 댓글
* @param string $slug 게시판 슬러그
* @return void
*/
public function afterCommentDelete(Comment $comment, string $slug): void
{
$this->sendCommentActionNotification($comment, $slug, 'deleted');
}
/**
* 댓글 복원 알림을 발송합니다.
*
* @param Comment $comment 처리된 댓글
* @param string $slug 게시판 슬러그
* @return void
*/
public function afterCommentRestore(Comment $comment, string $slug): void
{
$this->sendCommentActionNotification($comment, $slug, 'restored');
}
/**
* 신고/자동 블라인드 처리 댓글 알림을 발송합니다.
*
* 신고 또는 자동 블라인드로 인해 처리(블라인드/삭제/복원)된 댓글의 작성자에게 알림을 발송합니다.
* 댓글 직권 처리(Admin) 알림은 이번 범위 제외입니다.
*
* @param Comment $comment 처리된 댓글
* @param string $slug 게시판 슬러그
* @param string $actionType 처리 유형 (blind, deleted, restored)
* @return void
*/
private function sendCommentActionNotification(Comment $comment, string $slug, string $actionType): void
{
// 중복 실행 방지
$key = "comment_action_{$actionType}_{$slug}_{$comment->id}";
if (isset(self::$processedKeys[$key])) {
return;
}
self::$processedKeys[$key] = true;
if (! $comment->user_id) {
return;
}
// 관리자가 처리한 경우에만 알림 발송 (본인이 직접 삭제한 경우 제외)
$currentUserId = Auth::id();
if ($currentUserId === $comment->user_id) {
return;
}
// 신고 처리 또는 자동 블라인드인 경우에만 알림 발송 (댓글 직권 처리 알림은 별도 이슈)
if ($comment->trigger_type !== TriggerType::Report && $comment->trigger_type !== TriggerType::AutoHide) {
return;
}
$reportPolicy = g7_module_settings('sirsoft-board', 'report_policy', []);
if (empty($reportPolicy['notify_author_on_report_action'])) {
return;
}
$board = $this->boardRepository->findBySlug($slug);
if (! $board) {
return;
}
$recipient = $comment->user;
if (! $recipient || ! $recipient->email) {
return;
}
try {
$recipient->notify(new ReportActionNotification(
postId: $comment->post_id,
boardId: $board->id,
slug: $slug,
postTitle: $comment->post->title ?? '',
actionType: $actionType,
targetType: 'comment'
));
Log::info('게시판 알림 발송: 신고/자동 블라인드 처리 (댓글)', [
'type' => 'report_action',
'trigger_type' => $comment->trigger_type?->value,
'action_type' => $actionType,
'board' => $slug,
'comment_id' => $comment->id,
'post_id' => $comment->post_id,
'recipient_id' => $comment->user_id,
]);
} catch (\Exception $e) {
Log::error('게시판 알림 발송 실패: 신고/자동 블라인드 처리 (댓글)', [
'type' => 'report_action',
'action_type' => $actionType,
'board' => $slug,
'comment_id' => $comment->id,
'post_id' => $comment->post_id,
'recipient_id' => $comment->user_id,
'error' => $e->getMessage(),
]);
}
}
/**
* 글 작성자에게 새 댓글 알림을 발송합니다.
*
* @param Board $board 게시판
* @param Post $post 게시글
* @param Comment $comment 댓글
* @param string $slug 게시판 슬러그
* @param string $commentAuthorName 댓글 작성자 이름
* @return void
*/
private function notifyPostAuthorOnComment(
Board $board,
Post $post,
Comment $comment,
string $slug,
string $commentAuthorName
): void {
// 게시판 설정 확인
if (! $board->notify_author) {
return;
}
// 회원 게시글만
if (! $post->user_id) {
return;
}
// 자기 자신 제외
if ($post->user_id === $comment->user_id) {
return;
}
// 사용자 알림 설정 확인
$settings = $this->notificationSettingService->getByUserId($post->user_id);
if (! $settings || ! $settings->notify_comment) {
return;
}
// 수신자 이메일 확인
$recipient = $post->user;
if (! $recipient || ! $recipient->email) {
return;
}
try {
$recipient->notify(new NewCommentNotification(
commentId: $comment->id,
boardId: $board->id,
postId: $post->id,
slug: $slug,
commentAuthorName: $commentAuthorName
));
Log::info('게시판 알림 발송: 새 댓글', [
'type' => 'new_comment',
'board' => $slug,
'post_id' => $post->id,
'comment_id' => $comment->id,
'recipient_id' => $post->user_id,
]);
} catch (\Exception $e) {
Log::error('게시판 알림 발송 실패: 새 댓글', [
'type' => 'new_comment',
'board' => $slug,
'post_id' => $post->id,
'comment_id' => $comment->id,
'recipient_id' => $post->user_id,
'error' => $e->getMessage(),
]);
}
}
/**
* 부모 댓글 작성자에게 대댓글 알림을 발송합니다.
*
* @param Board $board 게시판
* @param Post $post 게시글
* @param Comment $comment 대댓글
* @param string $slug 게시판 슬러그
* @param string $commentAuthorName 대댓글 작성자 이름
* @return void
*/
private function notifyParentCommentAuthor(
Board $board,
Post $post,
Comment $comment,
string $slug,
string $commentAuthorName
): void {
// 게시판 설정 확인
if (! $board->notify_author) {
return;
}
if (empty($comment->parent_id)) {
return;
}
$parentComment = $this->commentRepository->find($slug, $comment->parent_id);
if (! $parentComment || ! $parentComment->user_id) {
return;
}
// 자기 자신 제외
if ($parentComment->user_id === $comment->user_id) {
return;
}
// 사용자 알림 설정 확인
$settings = $this->notificationSettingService->getByUserId($parentComment->user_id);
if (! $settings || ! $settings->notify_reply_comment) {
return;
}
// 수신자 이메일 확인
$recipient = $parentComment->user;
if (! $recipient || ! $recipient->email) {
return;
}
try {
$recipient->notify(new ReplyCommentNotification(
commentId: $comment->id,
parentCommentId: $parentComment->id,
boardId: $board->id,
postId: $post->id,
slug: $slug,
commentAuthorName: $commentAuthorName
));
Log::info('게시판 알림 발송: 대댓글', [
'type' => 'reply_comment',
'board' => $slug,
'post_id' => $post->id,
'comment_id' => $comment->id,
'parent_comment_id' => $parentComment->id,
'recipient_id' => $parentComment->user_id,
]);
} catch (\Exception $e) {
Log::error('게시판 알림 발송 실패: 대댓글', [
'type' => 'reply_comment',
'board' => $slug,
'post_id' => $post->id,
'comment_id' => $comment->id,
'parent_comment_id' => $parentComment->id,
'recipient_id' => $parentComment->user_id,
'error' => $e->getMessage(),
]);
}
}
/**
* 원글 작성자에게 답변글 알림을 발송합니다.
*
* @param Board $board 게시판
* @param Post $post 답변글
* @param string $slug 게시판 슬러그
* @return void
*/
private function notifyParentPostAuthorOnReply(Board $board, Post $post, string $slug): void
{
// 게시판 설정 확인
if (! $board->notify_author) {
return;
}
if (empty($post->parent_id)) {
return;
}
$parentPost = $this->postRepository->find($slug, $post->parent_id);
if (! $parentPost || ! $parentPost->user_id) {
return;
}
// 자기 자신 제외
if ($parentPost->user_id === $post->user_id) {
return;
}
// 사용자 알림 설정 확인
$settings = $this->notificationSettingService->getByUserId($parentPost->user_id);
if (! $settings || ! $settings->notify_post_reply) {
return;
}
// 수신자 이메일 확인
$recipient = $parentPost->user;
if (! $recipient || ! $recipient->email) {
return;
}
try {
$recipient->notify(new PostReplyNotification(
replyPostId: $post->id,
parentPostId: $parentPost->id,
boardId: $board->id,
slug: $slug,
replyPostTitle: $post->title ?? ''
));
Log::info('게시판 알림 발송: 답변글', [
'type' => 'post_reply',
'board' => $slug,
'reply_post_id' => $post->id,
'parent_post_id' => $parentPost->id,
'recipient_id' => $parentPost->user_id,
]);
} catch (\Exception $e) {
Log::error('게시판 알림 발송 실패: 답변글', [
'type' => 'post_reply',
'board' => $slug,
'reply_post_id' => $post->id,
'parent_post_id' => $parentPost->id,
'recipient_id' => $parentPost->user_id,
'error' => $e->getMessage(),
]);
}
}
/**
* 신고 생성 후 관리자 알림을 처리합니다.
*
* @param Report $report 생성된 신고
* @return void
*/
public function afterReportCreate(Report $report): void
{
$key = "report_create_{$report->id}";
if (isset(self::$processedKeys[$key])) {
return;
}
self::$processedKeys[$key] = true;
$this->notifyAdminOnReport($report);
}
/**
* 신고 접수 시 관리자에게 알림을 발송합니다.
*
* reports.manage 권한 보유자에게 발송하며, 권한자 미지정 시 알림을 발송하지 않습니다.
*
* @param Report $report 생성된 신고
* @return void
*/
private function notifyAdminOnReport(Report $report): void
{
$reportPolicy = g7_module_settings('sirsoft-board', 'report_policy', []);
if (empty($reportPolicy['notify_admin_on_report'])) {
return;
}
// 발송 범위 조건: per_case = 케이스당 1회 (신규 + 재활성화 첫 신고), per_report = 신고 건마다
$scope = $reportPolicy['notify_admin_on_report_scope'] ?? 'per_case';
if ($scope === 'per_case') {
// last_activated_at 이후(재활성 포함) 로그 수가 2 이상이면 재신고 → 발송 안함
$activeCycleLogCount = $report->logs()
->when($report->last_activated_at,
fn ($q) => $q->where('created_at', '>=', $report->last_activated_at)
)->count();
if ($activeCycleLogCount > 1) {
return;
}
}
// reports.manage 권한 보유자 조회 (역할→권한 경로), 미지정 시 알림 미발송
$recipients = User::whereHas('roles.permissions', function ($q) {
$q->where('identifier', 'sirsoft-board.reports.manage');
})->get();
if ($recipients->isEmpty()) {
return;
}
// Board 정보 로드 (slug 획득용)
$board = $report->board;
if (! $board) {
return;
}
// snapshot과 reason_type은 ReportLog에 저장됨 (Report에는 없음)
$report->loadMissing(['logs' => fn ($q) => $q->latest()]);
$log = $report->logs->first();
$postTitle = $log?->snapshot['title'] ?? '';
$boardName = $log?->snapshot['board_name'] ?? ($board->localizedName ?? $board->name);
$targetType = $report->target_type instanceof \BackedEnum
? $report->target_type->value
: (string) $report->target_type;
$reasonType = $log?->reason_type instanceof \BackedEnum
? $log->reason_type->value
: ($log?->reason_type ? (string) $log->reason_type : 'etc');
foreach ($recipients as $recipient) {
if (! $recipient->email) {
continue;
}
try {
$recipient->notify(new ReportReceivedAdminNotification(
reportId: $report->id,
boardId: $board->id,
slug: $board->slug,
boardName: $boardName,
postTitle: $postTitle,
targetType: $targetType,
reasonType: $reasonType,
));
Log::info('게시판 알림 발송: 신고 접수 (관리자)', [
'type' => 'report_received_admin',
'board' => $board->slug,
'report_id' => $report->id,
'recipient_id' => $recipient->id,
]);
} catch (\Exception $e) {
Log::error('게시판 알림 발송 실패: 신고 접수 (관리자)', [
'type' => 'report_received_admin',
'board' => $board->slug,
'report_id' => $report->id,
'recipient_id' => $recipient->id,
'error' => $e->getMessage(),
]);
}
}
}
private function notifyAdminOnNewPost(Board $board, Post $post, string $slug): void
{
if (! $board->notify_admin_on_post) {
return;
}
// 게시판 관리자 역할의 사용자 조회, 없으면 최고관리자(super_admin) 폴백
$managerRole = Role::where('identifier', "sirsoft-board.{$slug}.manager")->first();
$managers = $managerRole ? $managerRole->users()->get() : collect();
$isFallback = $managers->isEmpty();
if ($isFallback) {
$superAdmin = User::superAdmins()->first();
$managers = $superAdmin ? collect([$superAdmin]) : collect();
}
foreach ($managers as $manager) {
if (! $manager->email) {
continue;
}
// 자기 자신 제외 (관리자가 직접 작성한 경우)
if ($manager->id === $post->user_id) {
continue;
}
try {
$manager->notify(new NewPostAdminNotification(
postId: $post->id,
boardId: $board->id,
slug: $slug,
postTitle: $post->title ?? ''
));
Log::info('게시판 알림 발송: 관리자 새글', [
'type' => 'new_post_admin',
'board' => $slug,
'post_id' => $post->id,
'recipient_id' => $manager->id,
'is_super_admin_fallback' => $isFallback,
]);
} catch (\Exception $e) {
Log::error('게시판 알림 발송 실패: 관리자 새글', [
'type' => 'new_post_admin',
'board' => $slug,
'post_id' => $post->id,
'recipient_id' => $manager->id,
'is_super_admin_fallback' => $isFallback,
'error' => $e->getMessage(),
]);
}
}
}
}
Loaded 100 of 1165 files, more files were not shown because too many files have changed in this diff. Show more