v7.0.0-beta.2 release
This commit is contained in:
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
+43
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+38
@@ -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"])');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+42
@@ -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가 수동으로 수행해야 합니다.'
|
||||
);
|
||||
}
|
||||
};
|
||||
+82
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+51
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+360
@@ -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>';
|
||||
}
|
||||
}
|
||||
+483
@@ -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']],
|
||||
|
||||
@@ -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": {}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
-581
@@ -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');
|
||||
});
|
||||
});
|
||||
+179
-21
@@ -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."
|
||||
}
|
||||
+15
@@ -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)는 게시판의 표시 유형을 구분하는 식별자입니다. 템플릿에서 해당 슬러그에 맞는 전용 레이아웃을 추가 및 설정해야 적용됩니다."
|
||||
}
|
||||
+15
@@ -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 생성 (신규/수정 모두 - 취소 시 안전을 위해)",
|
||||
|
||||
+16
-7
@@ -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",
|
||||
|
||||
+59
-53
@@ -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": {
|
||||
|
||||
+162
-128
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
-54
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+45
-12
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+16
-4
@@ -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",
|
||||
|
||||
+43
-30
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+14
-3
@@ -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",
|
||||
|
||||
+15
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
{
|
||||
|
||||
-641
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+175
@@ -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'}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+915
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
-10
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-29
@@ -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" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -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'}}",
|
||||
|
||||
+2
-22
@@ -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" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -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'}}",
|
||||
|
||||
+1
-1
@@ -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'}}",
|
||||
|
||||
+1
-1
@@ -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'}}",
|
||||
|
||||
+1
-1
@@ -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'}}",
|
||||
|
||||
-721
@@ -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]}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+707
@@ -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]}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-30
@@ -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" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+728
@@ -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" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -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(',');
|
||||
|
||||
-176
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-27
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
+47
-10
@@ -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
Reference in new issue
Block a user