v7.0.0 release

This commit is contained in:
HeuJung committed 2026-07-01 10:30:32 +09:00
1 parent 0e7fa04b03
commit c4ea9a6cd1
3582 files changed
+626189 -160151

No files matched your search

@@ -4,7 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [0.1.0-beta.1] - 2026-05-11
## [0.1.0] - 2026-07-01
### Changed
- 메모 관리 화면의 콘텐츠 카드 외형을 sirsoft-admin_basic 표준 시맨틱 (.admin-card) 과 정합 — 다른 관리자 화면과 같은 결로 통일.
- 관리자 화면 좌우 정렬 컨테이너(섹션 헤더 · 액션바 · 카드 상단부 등) 외형을 표준 시맨틱(.flex-between)으로 정리 — 시각 변경 없음, 향후 톤 조정이 한 곳에서 가능.
### Added
@@ -13,3 +18,4 @@
- 훅 발행 데모 (`gnuboard7-hello_module.memo.created`)
- 기본 권한 4종 (read, create, update, delete) 및 관리자 메뉴 등록
- 다국어 지원 (ko, en)
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
@@ -2,7 +2,7 @@
"name": "modules/gnuboard7-hello_module",
"description": "Hello sample module for Gnuboard7 (learning purpose)",
"type": "library",
"version": "0.1.0-beta.1",
"version": "0.1.0",
"license": "MIT",
"autoload": {
"psr-4": {
+20
View File
@@ -0,0 +1,20 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4eddbf75b302677f7e516f8a10d236dc",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
@@ -5,13 +5,13 @@
"ko": "Hello 모듈",
"en": "Hello Module"
},
"version": "0.1.0-beta.1",
"version": "0.1.0",
"license": "MIT",
"description": {
"ko": "학습용 최소 샘플 모듈 (Memo CRUD)",
"en": "Learning minimal sample module (Memo CRUD)"
},
"g7_version": ">=7.0.0-beta.4",
"g7_version": ">=7.0.0",
"hidden": true,
"dependencies": {
"modules": {},
@@ -1,4 +1,10 @@
{
"editor": {
"data_source": {
"memoData": "Memo Data",
"memos": "Memos"
}
},
"admin": {
"memos": {
"index": {
@@ -1,4 +1,10 @@
{
"editor": {
"data_source": {
"memoData": "메모 데이터",
"memos": "메모 목록"
}
},
"admin": {
"memos": {
"index": {
@@ -11,6 +11,7 @@
"data_sources": [
{
"id": "memoData",
"label_key": "$t:gnuboard7-hello_module.editor.data_source.memoData",
"type": "api",
"endpoint": "/api/modules/gnuboard7-hello_module/admin/memos/{{route?.id}}",
"method": "GET",
@@ -44,7 +45,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 bg-gray-50 dark:bg-gray-900 min-h-screen"
"className": "admin-page-content"
},
"children": [
{
@@ -61,7 +62,7 @@
"type": "basic",
"name": "H1",
"props": {
"className": "text-2xl font-bold text-gray-900 dark:text-white mb-6"
"className": "text-page-heading mb-6"
},
"text": "{{route?.id ? '$t:gnuboard7-hello_module.admin.memos.form.title_edit' : '$t:gnuboard7-hello_module.admin.memos.form.title_create'}}"
},
@@ -77,7 +78,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
"className": "form-label-block"
},
"text": "$t:gnuboard7-hello_module.admin.memos.form.fields.title"
},
@@ -88,7 +89,7 @@
"props": {
"type": "text",
"name": "title",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
"className": ""
}
}
]
@@ -105,7 +106,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
"className": "form-label-block"
},
"text": "$t:gnuboard7-hello_module.admin.memos.form.fields.content"
},
@@ -115,7 +116,7 @@
"props": {
"name": "content",
"rows": 6,
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
"className": ""
}
}
]
@@ -125,7 +126,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-end gap-2"
"className": "flex-center justify-end gap-2"
},
"children": [
{
@@ -12,6 +12,7 @@
"data_sources": [
{
"id": "memos",
"label_key": "$t:gnuboard7-hello_module.editor.data_source.memos",
"type": "api",
"endpoint": "/api/modules/gnuboard7-hello_module/admin/memos",
"method": "GET",
@@ -41,7 +42,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 bg-gray-50 dark:bg-gray-900 min-h-screen"
"className": "admin-page-content"
},
"children": [
{
@@ -49,7 +50,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-6"
"className": "flex-between mb-6"
},
"children": [
{
@@ -57,7 +58,7 @@
"type": "basic",
"name": "H1",
"props": {
"className": "text-2xl font-bold text-gray-900 dark:text-white"
"className": "text-page-heading"
},
"text": "$t:gnuboard7-hello_module.admin.memos.index.title"
},
@@ -93,7 +94,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
"className": "row-stack"
},
"iteration": {
"data": "{{memos?.data?.data ?? []}}",
@@ -113,7 +114,7 @@
"type": "basic",
"name": "H2",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
"className": "section-heading"
},
"text": "{{memo.title ?? ''}}"
},
@@ -8,6 +8,7 @@
"data_sources": [
{
"id": "memos",
"label_key": "$t:gnuboard7-hello_module.editor.data_source.memos",
"type": "api",
"endpoint": "/api/modules/gnuboard7-hello_module/memos",
"method": "GET",
@@ -45,7 +46,7 @@
"type": "basic",
"name": "H1",
"props": {
"className": "text-2xl font-bold text-gray-900 dark:text-white mb-6"
"className": "text-page-heading mb-6"
},
"text": "$t:gnuboard7-hello_module.user.memos.index.title"
},
@@ -74,7 +75,7 @@
"type": "basic",
"name": "H2",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
"className": "section-heading"
},
"text": "{{memo.title ?? ''}}"
},
+109 -4
View File
@@ -4,12 +4,117 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-01
### Added
- 관리자 대시보드의 게시판 "오늘 새 글/새 댓글" 배지를 클릭하면 게시판 관리 화면으로 바로 이동합니다.
- 게시판 생성/수정 화면에 "관리자 메뉴에 표시" 토글을 추가했습니다. 켜고 저장하면 좌측 관리자 메뉴의 "게시판 관리" 아래에 해당 게시판이 표시되고, 끄고 저장하면 메뉴에서 제거됩니다. 이전에는 게시판을 만든 뒤 수정 화면에서만 메뉴에 추가할 수 있었습니다. 게시판을 복사할 때도 원본의 관리자 메뉴 표시 상태가 복사본에 그대로 이어집니다.
#### 레이아웃 편집기
- 게시판 화면을 샘플 데이터로 미리보고 편집할 수 있습니다 — 게시글 목록·상세·댓글, 게시판 관리/신고 화면이 샘플 데이터로 표시되며, 일반 글/비밀글 잠금, 글 있음/없음 같은 화면 상태도 미리보기로 전환해 볼 수 있습니다.
- 화면 상태 미리보기를 더 많은 게시판 화면으로 넓혔습니다 — 게시판 목록의 비어 있음, 글 작성/수정, 관리자의 게시판·게시글 폼(신규/수정/답글), 그리고 게시판 설정의 모든 탭(일반·기본값·신고 정책·스팸 보안·SEO·알림 정의·본인인증 정책)을 전환해 미리볼 수 있습니다.
- 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
- [검색엔진] 탭에서 게시글의 공유 이미지·제목·구조화 데이터 등 이 확장이 제공하는 SEO 자동값이 "게시글 제목"·"대표 이미지" 같은 데이터 연결 칩(출처 표시 포함)으로 보이고, 다른 데이터로 바꿔 지정할 수 있습니다.
#### 관리자 대시보드 게시판 영역
- 관리자 대시보드 상단에 게시판 / 신고 빠른 진입 버튼을 추가 — 게시판 모듈이 활성화된 환경에서만 노출되며, 비활성화 시 자동으로 사라짐
- 관리자 대시보드 중앙에 게시판 통계 카드 영역 추가 — 오늘의 새 글·새 댓글 배지, 7일 게시글·댓글 추세 그래프(직전 7일 대비 증감율 포함), 최신 게시글 5건, 미처리 신고 5건 카드로 구성. 라이트/다크 모드 양쪽에서 텍스트·구분선·배지가 일관되게 보이도록 색상 페어 적용
- 게시판 모듈을 비활성화하면 위 버튼·카드 영역이 모두 사라지고, 재활성화하면 새로고침으로 자동 복귀
- 매 시간 자동 실행되는 게시판 집계 스케줄러 추가 — 게시글·댓글 통계를 일별로 누적하여 대시보드 카드와 추세 그래프의 데이터 출처가 됨. 수동 실행도 가능
### Changed
- 코어 최소 요구 버전을 7.0.0-beta.8 로 상향. SEO 자동값 연결 칩 편집 기능이 코어 7.0.0-beta.8 에서 제공하는 편집기 연동 표면을 사용합니다.
- 게시글·댓글 작성일 표시 형식을 코어 표준(`Y-m-d H:i:s`)으로 통일 — 이전의 한글 요일 포함 표기가 영문/일본어 사이트에서도 한글로 출력되던 다국어 미정합과 이커머스 주문일시와의 표기 불일치를 해소. 시드/시계 어긋남 등으로 미래 시각이 들어오는 경계 케이스도 안정적으로 처리
- 신고 정책 설정 화면의 알림 영역을 개편 — 별도 활성화 토글을 제거하고, 신고 접수·처리 알림이 채널별(이메일/사이트 알림)로 어떤 템플릿으로 발송되는지와 활성/비활성 상태를 한눈에 보여줍니다. 채널·템플릿·수신자 관리는 "알림 설정으로 이동" 버튼으로 알림 설정 메뉴에서 일원화했습니다.
- 게시판 설정의 금지어·허용 확장자 입력 방식을 태그 입력으로 통일했습니다 — 쉼표로 구분해 한 줄에 적던 방식에서, 단어를 입력하고 Enter 를 누르면 칩으로 하나씩 추가되는 방식으로 바뀌어 입력·삭제가 직관적입니다.
- 관리자 게시글 상세의 댓글 카드 우측 버튼 영역(답변/신고/더보기) 사이 간격을 일관되게 조정 — 버튼이 붙어 있던 시각 결함 해소
#### 관리자 화면 외형 정합
- 게시판 관리 전반(환경설정 전 탭·게시판 폼·게시글 폼·신고 상세·목록·모달·검색바)의 카드·입력 칸·라벨·본문 텍스트·구분선 모양과 간격을 다른 관리자 화면과 동일한 결로 통일했습니다 — 화면마다 조금씩 다르게 보이던 카드 모서리·여백·헤더 간격, 입력 칸 테두리·배경, 글자 톤이 한 가지 기준으로 정합됩니다.
- 게시판 생성/수정 폼 "권한" 탭의 접근 제어 영역과 신고 상세 화면에서 항목·카드 사이 간격이 의도보다 부풀어 보이던 회귀를 바로잡아 다른 탭·화면과 동일한 간격으로 표시됩니다.
- 게시판 신고 정책 탭의 "채널별 발송 상태"에서 사이트 알림/이메일 아이콘이 라벨 위로 올라가 비대칭으로 보이던 회귀를 바로잡아, 아이콘이 채널 라벨 옆 가운데로 정렬됩니다.
- 이커머스 환경설정의 문의 게시판 선택 항목에서 라벨과 선택 박스 사이에 옅은 분리선·간격이 끼어 보이던 회귀를 바로잡아 한 필드로 응집됩니다.
- 게시판 댓글 패널 상단 헤더의 하단 분리선이 흐려져 헤더와 본문 경계가 모호하게 보이던 회귀를 바로잡았습니다.
- 게시판 관리 화면 곳곳의 역할 없는 빈 컨테이너 중첩을 정리해, 화면 모양은 그대로 둔 채 페이지 구조만 단순해졌습니다.
### Fixed
#### 폼·환경설정·정책 편집
- 관리자 대시보드의 게시판 위젯(최신 게시글·신고 관리 목록)에서 각 항목이 같은 화면 식별자를 공유해 일부 보조 기능·접근성 도구가 항목을 구분하지 못하던 문제를 수정했습니다 — 이제 목록의 각 행이 고유하게 식별됩니다.
- 게시판 본인인증 정책 편집 화면에서 "인증 목적"과 "적용 대상"을 바꿔 저장해도 반영되지 않던 문제를 수정했습니다 — 이제 정책 키·강제 시점·강제 위치를 제외한 모든 항목(인증 목적·적용 대상 등)을 자유롭게 변경할 수 있으며, 변경한 값은 업데이트 후에도 유지됩니다.
- 게시판 생성/수정 폼 "게시글 설정" 탭에서 필드 사이 구분선이 일부 그룹 경계에만 그어지고 일부 필드 사이에는 누락되어 보이던 회귀를 수정했습니다 — 다른 탭과 동일하게 모든 필드 사이에 일관된 구분선이 표시됩니다.
- 게시판 생성/수정 폼에서 탭을 클릭하거나 본문을 스크롤할 때 상단 탭 강조가 따라가지 않던 회귀를 수정했습니다 — 활성 탭이 현재 보고 있는 영역에 맞춰 자동으로 강조됩니다.
- 게시판 환경설정에서 게시판 목록·개별 게시판·게시글 상세의 "SEO 제공 페이지"를 꺼도 검색엔진 사이트맵(sitemap.xml)에 해당 주소가 그대로 남던 문제 수정 — 끈 페이지 유형의 주소는 사이트맵에서 제외됩니다.
#### 삭제·복원·첨부·활동이력
- 답글이 달린 댓글을 삭제하면 그 댓글뿐 아니라 아래 답글까지 함께 사라지던 문제를 수정했습니다 — 삭제된 댓글 자리에는 "삭제된 댓글입니다" 표시가 남고, 다른 사람이 단 답글은 그대로 보입니다.
- 게시판 첨부파일을 내려받을 때 관리자 활동이력에 다운로드 기록이 남지 않던 문제를 수정했습니다 — 이제 첨부파일 다운로드도 업로드·삭제와 동일하게 활동이력에 기록되며, 활동이력에 표시되던 파일 이름이 비어 있던 문제도 함께 해소되어 어떤 파일을 다루었는지 확인할 수 있습니다.
- 로그인한 회원이 첨부파일을 내려받아도 활동이력의 "받은 사람"이 비어 있던 문제를 수정했습니다 — 이제 회원이 첨부를 내려받으면 누가 받았는지 활동이력에 정확히 남습니다. (로그인하지 않은 방문자의 다운로드는 종전과 같이 받은 사람 없이 기록됩니다.)
- 게시판을 삭제하면 소속 게시글·댓글·첨부파일과 첨부 원본 파일까지 함께 완전히 삭제되도록 수정했습니다 — 게시판이 사라진 뒤에도 글·댓글·첨부 데이터와 파일이 남아 용량을 차지하던 문제가 해소됩니다. (신고 접수 내역은 처리 기록 보존을 위해 그대로 유지됩니다.)
- 게시글을 삭제하면 그 글의 댓글·첨부파일도 함께 숨겨지고, 글을 복원하면 함께 부활하도록 수정했습니다 — 사용자가 글 삭제 전에 따로 지운 댓글·첨부는 복원해도 그대로 유지됩니다.
- 삭제된 게시글을 게시판 관리 권한자가 열었을 때 첨부파일이 보이지 않던 문제와, 글과 함께 숨겨진 댓글이 "삭제된 댓글입니다"로 가려지던 문제를 수정했습니다 — 이제 관리 권한자에게는 글과 함께 숨겨진 댓글의 원문과 첨부파일이 그대로 보입니다. (사용자가 직접 지운 댓글은 이전과 같이 가려진 채로 표시됩니다.)
#### 게시판 설정 저장·검증
- 게시판 설정을 여러 게시판에 일괄 적용하던 중 일부 게시판에서 오류가 나면, 어느 게시판에서 실패했는지 알려주고 변경 전체를 자동으로 되돌리도록 수정했습니다 — 일부 게시판에만 설정이 적용되어 게시판마다 설정이 달라지던 문제가 해소됩니다. (전부 적용되거나 전부 적용되지 않으며, 실패 시 안내가 표시됩니다.)
- 첨부 "허용 확장자"를 비워 둔 채 저장하면 안내와 반대로 모든 파일 업로드가 거부되던 문제를 수정했습니다 — 이제 파일 첨부를 사용하는 게시판은 허용 확장자를 최소 1개 이상 입력해야 저장되며, 안내 문구도 이에 맞게 변경되었습니다. (파일 첨부를 사용하지 않는 게시판은 확장자를 비워 둘 수 있습니다.)
- 게시판 샘플 시더가 댓글·답글·첨부 비정규화 카운트를 동기화하지 않아, 시드 직후 게시글 상세 화면에서 댓글/답글 영역이 미표시되던 결함 해소
- 게시판 환경설정 "게시판 설정" 탭의 숫자 입력 필드에 허용 범위 안내(예: 5~100)를 표시하고, 입력칸의 최소·최대값을 실제 저장 한도와 일치시켰습니다. 답변글 깊이는 최대 10단계까지, 첨부 파일 용량 상한도 정상 적용됩니다. 모바일 페이지당 게시글 수 입력칸의 범위 안내와 최소·최대값도 실제 저장 한도를 따르도록 정합화했습니다.
- 환경설정에서 저장한 제목·내용·댓글 글자 수 상한이 실제 게시판 글쓰기 검증과 어긋나 저장 후 글 작성이 막히던 문제 해소 — 제목 최대 1000자, 내용 최대 100000자, 댓글 최대 10000자까지 일관되게 적용됩니다.
- 게시판 설정에서 최소 글자 수를 최대 글자 수보다 크게 저장할 수 있던 문제 해소 — 제목·내용·댓글 각각 최소값이 최대값보다 크면 저장이 막히고, 해당 입력칸에 안내 메시지가 표시됩니다.
- 게시판마다 설정한 "신규 게시글 표시 기간"이 게시글 목록의 [New] 배지에 반영되지 않고 항상 24시간 기준으로 표시되던 문제 해소 — 이제 설정한 기간(예: 1시간, 48시간)에 맞춰 목록의 [New] 배지가 표시됩니다.
- 게시판 이름·설명을 수정한 뒤에도 검색엔진에 이전 제목·설명이 계속 노출되던 문제 해소 — 게시판 정보를 수정하면 해당 게시판 목록 페이지의 검색엔진용 메타 정보가 즉시 갱신됩니다.
#### 복사·권한·역할
- 게시판을 복사할 때 슬러그만 바꿔 저장하면 "관리자 값은 필수입니다" 오류로 저장이 막히던 문제 해소 — 복사 화면에 원본의 관리자·작업 단계 담당자 명단이 미리 채워지고, 권한 구성도 원본과 동일하게 복사됩니다. 복사본에는 새 게시판 전용 관리자 역할이 부여되며, 원본 게시판의 관리자가 복사본 권한에 섞여 들어가던 권한 누수도 차단했습니다.
- 게시판 복사 확인 창의 안내를 정리해, 무엇이 복사되고(설정·권한·관리자) 무엇이 제외되는지(게시글·댓글), 저장 전 새 슬러그를 확인해야 한다는 점을 한눈에 보이도록 했습니다.
- 게시판을 새로 만들거나 복사한 직후 권한 설정 탭의 역할 목록에 게시판 관리자·스텝 역할이 바로 표시되지 않고 새로고침해야 나타나던 문제 해소 — 저장 직후 화면에서도 부여된 역할이 모두 정상 표시됩니다.
- "게시판 관리" 권한을 받은 사용자가 게시판 화면에서는 관리 가능으로 보이는데도 실제로 다른 사람의 글·댓글을 수정하거나 삭제하려 하면 차단되던 문제 해소 — 이제 게시판 관리 권한만으로 타인 글·댓글의 수정/삭제와 수정 화면 진입이 정상 동작합니다.
- 게시판 권한 설정에서 "비밀글 조회" 권한의 역할을 비우고 저장할 때, 안내 메시지에 권한 이름이 영문 키(`posts.read-secret`)로 표시되던 문제 해소 — 다른 권한과 동일하게 "비밀글 조회"로 표시됩니다.
#### 노출 제어·분류·알림·삭제글
- 블라인드 처리된 게시글·댓글의 원문이 권한 없는 사용자에게도 '원글 보기' 버튼으로 그대로 열람되던 문제 해소 — 이제 원문은 게시판 관리자와 작성자 본인만 볼 수 있습니다. 목록의 제목·블라인드 표시와 블라인드 사유 안내는 이전과 동일하게 모두에게 표시됩니다.
- 게시판 목록·검색 결과에서 비밀글의 본문 요약이 권한 없는 사용자에게도 보이던 문제 해소 — 이제 비밀글과 블라인드 글의 본문 요약은 목록·검색에서 표시되지 않습니다. 제목은 이전과 동일하게 표시되며, 본문은 상세 화면에서 열람 권한이 있는 경우에만 볼 수 있습니다.
- 게시판 분류 개수 제한이 적용되지 않아 분류를 무제한으로 추가할 수 있던 문제 해소 — 분류는 최대 50개까지만 추가할 수 있으며, 빈 분류명은 저장되지 않습니다.
- 게시판 분류 입력 안내를 명확히 했습니다 — 분류를 삭제해도 해당 분류의 게시글은 사라지지 않고 "미분류" 필터에서 계속 조회할 수 있음을 안내합니다.
- 게시판에 설정한 금지어가 게시글에만 적용되고 댓글에는 적용되지 않던 문제 해소 — 이제 금지어가 포함된 댓글도 작성·수정 시 동일하게 차단됩니다.
- 분류가 있는 게시판에서 분류를 선택하지 않아도 글이 등록되던 문제 해소 — 이제 분류가 있는 게시판은 글 작성·수정 시 분류 선택이 필수이며, 작성 화면의 분류 항목에 필수 표시와 안내 메시지가 나타납니다.
- 내 알림 설정에서 "게시글 처리 알림"을 꺼도 관리자가 내 글·댓글을 블라인드·삭제·복원할 때 알림이 계속 발송되던 문제 해소 — 이제 이 알림을 끄면 해당 알림을 받지 않으며, 댓글·답글 등 다른 알림과 동일하게 개인 설정이 적용됩니다.
- 관리자 회원 수정 화면에 게시판 알림 설정 영역이 보이지 않던 문제 해소 — 이제 "게시판 알림 설정" 탭에서 게시글 처리·답변·댓글·답글 알림 수신 여부를 회원별로 켜고 끌 수 있습니다. 회원 상세 화면과 동일한 위치·구성으로 표시됩니다.
- 게시판 환경설정의 본인인증 정책 탭에서 출처 필터(전체/코어/모듈/플러그인)를 골라도 결과가 항상 비어 보이던 문제 해소 — 이 탭은 게시판 정책만 보여주는 화면이므로, 동작하지 않던 출처 필터를 정리했습니다. 강제 시점 필터와 검색은 그대로 사용할 수 있으며, 전체 출처의 본인인증 정책은 환경설정의 본인인증 화면에서 관리합니다.
- 블라인드·삭제된 원글에 답글 작성 폼으로 직접 들어가면 작성 폼이 먼저 열리며 원글의 제목·내용이 보이던 문제 해소 — 이제 블라인드·삭제된 원글에는 답글 작성 화면 진입 자체가 차단되어 원글 내용이 노출되지 않습니다.
- 삭제된 게시글·댓글의 원본을 게시판 관리 권한자가 볼 수 없던 문제 해소 — 이제 게시판 관리 권한이 있으면 삭제된 글·댓글의 원본 제목과 내용을 확인할 수 있습니다. 일반 사용자에게는 이전과 동일하게 노출되지 않습니다.
- 삭제된 게시글 상세에 답변 버튼이 그대로 보이던 문제 해소 — 삭제된 글에서는 답변 버튼이 표시되지 않습니다.
- 삭제된 게시글의 첨부파일이 권한 없는 사용자에게도 노출·다운로드되던 문제 해소 — 이제 삭제된 글의 첨부파일은 게시판 관리 권한자만 보거나 내려받을 수 있습니다.
#### 신고 관리·목록 탐색
- 신고 관리에서 영구삭제 처리된 신고 건을 다른 상태(접수·검토·반려·게시중단)로 되돌리려는 요청이 잘못 성공으로 응답되던 문제 해소 — 영구삭제는 최종 상태이므로 다른 상태로의 변경이 거부됩니다. 같은 상태로의 변경 등 허용되지 않는 상태 전환도 함께 차단됩니다.
- 신고 관리 목록(신고현황)에서 검색 필드(전체·게시글제목·게시판명·작성자명·신고자명)에 단어를 입력해 검색해도 결과가 걸러지지 않던 문제 해소 — 이제 검색 종류를 고르고 검색어를 입력하면 해당 조건으로 신고 목록이 정확히 걸러집니다.
- 게시글 목록에서 분류·검색·페이지를 적용한 뒤 글을 열고 "목록" 으로 돌아오면 적용했던 분류·검색·페이지가 풀려 항상 첫 화면으로 돌아오던 문제 해소 — 이제 글 상세로 들어갔다 나와도 보던 목록 상태가 그대로 복원됩니다.
- 분류가 있는 게시판에서 글 상세의 이전/다음 글 버튼이 다른 분류의 글로 넘어가던 문제 해소 — 이제 이전/다음 글은 현재 보고 있는 글과 같은 분류의 글로만 이동합니다. 분류가 없는 글은 이전과 동일하게 전체 글을 기준으로 이동합니다.
- 답글 상세에서 이전/다음 글 버튼이 자기 글타래와 무관한 엉뚱한 원글로 이동하던 문제 해소 — 답글 상세에서는 이전/다음 글 버튼이 표시되지 않으며, 원글로 이동하려면 답글 상단의 원글 링크를 사용합니다.
## [1.0.0-beta.5] - 2026-05-12
### Added
- 게시판 환경설정의 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동 비활성화
### Changed
- 게시판 관리 화면 (환경설정 · 게시판 폼 · 모달 · 검색바) 의 입력 박스 외형을 관리자 표준 외형으로 정렬 — 텍스트·숫자·일자 입력이 슬러그 입력과 같은 모양으로 보이도록 일관화.
- 게시판 환경설정 화면의 페이지 타이틀을 일반 흐름으로 정리하고 (스크롤 시 함께 따라오던 동작 해제), 취소·저장 버튼을 화면 하단 고정(스티키) 으로 이전 — 탭은 기존과 동일하게 상단 고정 유지.
## [1.0.0-beta.4] - 2026-05-11
### Added
@@ -184,10 +289,10 @@
### Fixed
- 신규 게시판 생성 시 조회수 표시가 기본 OFF로 설정되던 문제 수정 — 기본값을 ON으로 변경 (#228)
- 신규 게시판 생성 시 최소 내용 길이 기본값이 10자로 설정되던 문제 수정 — 기본값을 2자로 완화 (#228)
- 파일 크기 설정 단위를 bytes에서 MB로 통일 — 관리자 설정 입력, DB 저장, 검증, 안내 문구 모두 MB 기준으로 변경 (#228)
- 게시글 삭제(소프트삭제) 시 댓글과 첨부파일이 함께 삭제되어 복원 시 미표시되던 문제 수정 — 게시글만 삭제하고 댓글/첨부파일은 유지 (#228)
- 신규 게시판 생성 시 조회수 표시가 기본 OFF로 설정되던 문제 수정 — 기본값을 ON으로 변경
- 신규 게시판 생성 시 최소 내용 길이 기본값이 10자로 설정되던 문제 수정 — 기본값을 2자로 완화
- 파일 크기 설정 단위를 bytes에서 MB로 통일 — 관리자 설정 입력, DB 저장, 검증, 안내 문구 모두 MB 기준으로 변경
- 게시글 삭제(소프트삭제) 시 댓글과 첨부파일이 함께 삭제되어 복원 시 미표시되던 문제 수정 — 게시글만 삭제하고 댓글/첨부파일은 유지
## [0.8.0] - 2026-03-30
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
"version": "1.0.0-beta.5",
"version": "1.0.0",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -15,4 +15,4 @@
"require": {
"php": "^8.2"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f3ccc886d4904110ff4788fd5bc9e15d",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
@@ -121,19 +121,19 @@ return [
'min_title_length_min' => 0,
'min_title_length_max' => 200,
'max_title_length_min' => 1,
'max_title_length_max' => 200,
'max_title_length_max' => 1000,
// 내용 길이 제한 (LONGTEXT)
'min_content_length_min' => 0,
'min_content_length_max' => 10000,
'max_content_length_min' => 1,
'max_content_length_max' => 50000,
'max_content_length_max' => 100000,
// 댓글 길이 제한 (TEXT)
'min_comment_length_min' => 0,
'min_comment_length_max' => 1000,
'max_comment_length_min' => 1,
'max_comment_length_max' => 1000,
'max_comment_length_max' => 10000,
// 파일 업로드 제한 (MB 단위)
'max_file_size_min' => 1,
@@ -41,7 +41,7 @@ class BoardFactory extends Factory
'show_view_count' => false,
'use_report' => false,
'use_file_upload' => false,
'max_file_size' => 10485760, // 10MB
'max_file_size' => 10, // 10MB
'max_file_count' => 5,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'],
'min_title_length' => 2,
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* 대시보드용 일별 게시물/댓글 집계 테이블.
* 스케쥴러(sirsoft-board:aggregate-stats)가 하루 1행씩 upsert 하며,
* 대시보드 API 는 원본 풀스캔 없이 이 테이블만 읽는다.
*/
public function up(): void
{
Schema::create('board_stats', function (Blueprint $table) {
$table->id()->comment('집계 행 ID');
$table->date('date')->unique()->comment('집계 기준 날짜 (하루 1행, 멱등 upsert 키)');
$table->unsignedInteger('post_count')->default(0)->comment('해당 날짜 작성 게시글 수 (deleted_at IS NULL)');
$table->unsignedInteger('comment_count')->default(0)->comment('해당 날짜 작성 댓글 수 (deleted_at IS NULL)');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('board_stats');
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 대시보드 최신글 카드의 "전체 게시판 가로지르기" 쿼리 인덱스 보강.
*
* BoardDashboardService::getRecentPosts() → PostRepository::getRecentAcrossBoards()
* 가 사용하는 다음 패턴은 기존 인덱스가 모두 board_id 또는 user_id 를 첫 컬럼으로
* 두고 있어 활용 불가:
*
* WHERE deleted_at IS NULL AND parent_id IS NULL
* ORDER BY created_at DESC LIMIT N
*
* 1M 글 환경 실측에서 filesort 동반 약 3.7초 소요. 본 인덱스로 정렬 비용 제거.
*/
public function up(): void
{
Schema::table('board_posts', function (Blueprint $table) {
$table->index(
['deleted_at', 'parent_id', 'created_at'],
'idx_board_posts_recent_across_boards'
);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('board_posts', function (Blueprint $table) {
$table->dropIndex('idx_board_posts_recent_across_boards');
});
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* board_comments.trigger_type enum 에 'cascade' 값을 추가합니다.
* 게시글 삭제 시 함께 지워지는 댓글을 'cascade' 로 마킹하여, 게시글 복원 시
* 사용자가 직접 지운 댓글('user')과 구분해 선별 복원하기 위함입니다.
*/
public function up(): void
{
$prefix = DB::getTablePrefix();
// enum 에 'cascade' 추가 (조치 주체: 게시글 삭제 연쇄)
DB::statement(
"ALTER TABLE {$prefix}board_comments MODIFY COLUMN trigger_type "
."ENUM('report', 'admin', 'system', 'auto_hide', 'user', 'cascade') "
."NOT NULL DEFAULT 'admin' "
."COMMENT '조치 주체 (report: 신고, admin: 관리자, system: 시스템, auto_hide: 자동 블라인드, user: 사용자 직접 삭제, cascade: 게시글 삭제 연쇄)'"
);
}
/**
* Reverse the migrations.
*
* enum 축소 전, 잔존하는 'cascade' 행을 'admin' 으로 치환해야 롤백이 깨지지 않습니다.
* (enum 에 없는 값이 데이터에 남아 있으면 MODIFY COLUMN 이 실패)
*/
public function down(): void
{
$prefix = DB::getTablePrefix();
// 잔존 cascade 행을 admin 으로 치환 (enum 축소 안전 처리)
DB::table('board_comments')
->where('trigger_type', 'cascade')
->update(['trigger_type' => 'admin']);
DB::statement(
"ALTER TABLE {$prefix}board_comments MODIFY COLUMN trigger_type "
."ENUM('report', 'admin', 'system', 'auto_hide', 'user') "
."NOT NULL DEFAULT 'admin' "
."COMMENT '조치 주체 (report: 신고, admin: 관리자, system: 시스템, auto_hide: 자동 블라인드, user: 사용자 직접 삭제)'"
);
}
};
@@ -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
{
/**
* Run the migrations.
*
* board_attachments 에 trigger_type enum 컬럼을 추가합니다.
* 첨부에는 조치 주체 컬럼이 없어, 게시글 삭제 연쇄로 지워진 첨부('cascade')와
* 사용자가 직접 지운 첨부('user')를 구분할 수 없었습니다. 게시글 복원 시
* 선별 복원을 위해 댓글과 동일한 enum 값 세트를 부여합니다.
*/
public function up(): void
{
Schema::table('board_attachments', function (Blueprint $table) {
$table->enum('trigger_type', ['report', 'admin', 'system', 'auto_hide', 'user', 'cascade'])
->default('admin')
->after('created_by')
->comment('삭제 조치 주체 (admin: 관리자, user: 사용자 직접 삭제, cascade: 게시글 삭제 연쇄)');
$table->index(['board_id', 'post_id', 'trigger_type'], 'idx_board_post_trigger');
});
}
/**
* Reverse the migrations.
*
* 컬럼/인덱스 존재 확인 후 삭제 (방어적 코딩).
*/
public function down(): void
{
Schema::table('board_attachments', function (Blueprint $table) {
if (Schema::hasColumn('board_attachments', 'trigger_type')) {
$table->dropIndex('idx_board_post_trigger');
$table->dropColumn('trigger_type');
}
});
}
};
@@ -97,7 +97,7 @@ class BoardSampleSeeder extends Seeder
'use_reply' => false,
'use_report' => true,
'use_file_upload' => true,
'max_file_size' => 5242880, // 5MB
'max_file_size' => 5, // 5MB
'max_file_count' => 5,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'],
'show_view_count' => true,
@@ -136,7 +136,7 @@ class BoardSampleSeeder extends Seeder
'use_reply' => false,
'use_report' => true,
'use_file_upload' => true,
'max_file_size' => 10485760, // 10MB
'max_file_size' => 10, // 10MB
'max_file_count' => 10,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'show_view_count' => true,
@@ -169,7 +169,7 @@ class BoardSampleSeeder extends Seeder
'use_reply' => false,
'use_report' => false,
'use_file_upload' => true,
'max_file_size' => 10485760, // 10MB
'max_file_size' => 10, // 10MB
'max_file_count' => 5,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'show_view_count' => true,
@@ -204,7 +204,7 @@ class BoardSampleSeeder extends Seeder
'max_comment_depth' => 5,
'use_report' => true,
'use_file_upload' => true,
'max_file_size' => 5242880, // 5MB
'max_file_size' => 5, // 5MB
'max_file_count' => 3,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'pdf', 'zip', 'txt'],
'show_view_count' => true,
@@ -239,7 +239,7 @@ class BoardSampleSeeder extends Seeder
'max_comment_depth' => 10,
'use_report' => true,
'use_file_upload' => true,
'max_file_size' => 5242880, // 5MB
'max_file_size' => 5, // 5MB
'max_file_count' => 5,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'],
'show_view_count' => true,
@@ -274,7 +274,7 @@ class BoardSampleSeeder extends Seeder
'max_comment_depth' => 1,
'use_report' => false,
'use_file_upload' => true,
'max_file_size' => 5242880, // 5MB
'max_file_size' => 5, // 5MB
'max_file_count' => 3,
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'],
'show_view_count' => false,
@@ -6,7 +6,9 @@ use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Traits\HasSeederCounts;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -236,11 +238,11 @@ class PostSampleSeeder extends Seeder
* - archive : 10건 — 비활성 게시판, ASC 정렬 확인용
*/
private const POSTS_COUNT = [
'notice' => 15,
'free' => 30,
'notice' => 15,
'free' => 30,
'gallery' => 20,
'event' => 10,
'qna' => 30,
'event' => 10,
'qna' => 30,
'members' => 25,
'inquiry' => 20,
'archive' => 10,
@@ -260,15 +262,11 @@ class PostSampleSeeder extends Seeder
/**
* 관리자 사용자
*
* @var User|null
*/
private ?User $adminUser = null;
/**
* 스텝 사용자
*
* @var User|null
*/
private ?User $stepUser = null;
@@ -338,12 +336,105 @@ class PostSampleSeeder extends Seeder
$this->command->info('');
$this->command->info("게시글/댓글 샘플 데이터 생성 완료 — 총 게시글 {$totalStats['posts']}개, 답변글 {$totalStats['replies']}개, 댓글 {$totalStats['comments']}개");
// 시드는 모델 이벤트(BoardCommentsCountSyncListener / PostCountSyncListener)를
// 우회하는 raw INSERT 라 캐시 컬럼이 갱신되지 않는다. 시드 직후 게시글 상세 화면에서
// 댓글/답글 영역이 "comments_count > 0" 조건으로 가려져 표시 안 되는 결함이 발생하므로
// 일괄 재집계로 actual count 와 일치시킨다 (soft delete 제외).
$this->syncDenormalizedCounts();
}
/**
* 시드 종료 시 비정규화된 캐시 컬럼을 실데이터와 일괄 동기화한다.
*
* 대상:
* - posts.comments_count = 그 게시글의 미삭제 댓글 수
* - posts.replies_count = 그 게시글의 미삭제 답변글 수
* - posts.attachments_count = 그 게시글의 미삭제 첨부 수
* - comments.replies_count = 그 댓글의 미삭제 자식 댓글 수
* - boards.posts_count = 그 게시판의 미삭제 게시글 수 (답글 포함)
* - boards.comments_count = 그 게시판의 미삭제 댓글 수
*
* 동일 테이블 UPDATE + 서브쿼리는 MySQL 이 거부하므로 (Error 1093) 집계 결과를
* 파생 테이블(`JOIN (SELECT ... GROUP BY ...) t`) 로 끌어와 갱신한다.
*/
private function syncDenormalizedCounts(): void
{
$this->command->info('비정규화 카운트 컬럼 동기화 중...');
// posts.comments_count (다른 테이블 참조 — 직접 서브쿼리 가능)
DB::statement('
UPDATE g7_board_posts p
SET comments_count = (
SELECT COUNT(*) FROM g7_board_comments c
WHERE c.post_id = p.id AND c.deleted_at IS NULL
)
');
// posts.replies_count (자기 참조 — 파생 테이블 사용)
DB::statement('
UPDATE g7_board_posts p
LEFT JOIN (
SELECT parent_id, COUNT(*) AS cnt
FROM g7_board_posts
WHERE parent_id IS NOT NULL AND deleted_at IS NULL
GROUP BY parent_id
) t ON t.parent_id = p.id
SET p.replies_count = COALESCE(t.cnt, 0)
');
// posts.attachments_count (다른 테이블 참조 — 직접 서브쿼리 가능)
DB::statement('
UPDATE g7_board_posts p
SET attachments_count = (
SELECT COUNT(*) FROM g7_board_attachments a
WHERE a.post_id = p.id AND a.deleted_at IS NULL
)
');
// comments.replies_count (자기 참조 — 파생 테이블 사용)
DB::statement('
UPDATE g7_board_comments c
LEFT JOIN (
SELECT parent_id, COUNT(*) AS cnt
FROM g7_board_comments
WHERE parent_id IS NOT NULL AND deleted_at IS NULL
GROUP BY parent_id
) t ON t.parent_id = c.id
SET c.replies_count = COALESCE(t.cnt, 0)
');
// boards.posts_count (게시판별 미삭제 게시글 수 — 답글 포함)
DB::statement('
UPDATE g7_boards b
LEFT JOIN (
SELECT board_id, COUNT(*) AS cnt
FROM g7_board_posts
WHERE deleted_at IS NULL
GROUP BY board_id
) t ON t.board_id = b.id
SET b.posts_count = COALESCE(t.cnt, 0)
');
// boards.comments_count (게시판별 미삭제 댓글 수)
DB::statement('
UPDATE g7_boards b
LEFT JOIN (
SELECT board_id, COUNT(*) AS cnt
FROM g7_board_comments
WHERE deleted_at IS NULL
GROUP BY board_id
) t ON t.board_id = b.id
SET b.comments_count = COALESCE(t.cnt, 0)
');
$this->command->info(' ✅ posts.comments_count / posts.replies_count / posts.attachments_count / comments.replies_count / boards.posts_count / boards.comments_count 일괄 갱신 완료');
}
/**
* 관리자/스텝 역할 사용자를 조회합니다.
*
* @param \Illuminate\Support\Collection $users 사용자 목록
* @param Collection $users 사용자 목록
*/
private function resolveRoleUsers($users): void
{
@@ -412,7 +503,7 @@ class PostSampleSeeder extends Seeder
* 특정 게시판에 게시글과 댓글을 생성합니다.
*
* @param Board $board 게시판
* @param \Illuminate\Support\Collection $users 사용자 목록
* @param Collection $users 사용자 목록
* @param float $ratio 게시글 수 스케일링 비율
* @return array{posts: int, replies: int, comments: int} 생성 통계
*/
@@ -590,7 +681,7 @@ class PostSampleSeeder extends Seeder
* - 일반글 + 이미지 있음
*
* @param Board $board 게시판
* @param \Illuminate\Support\Collection $users 사용자 목록
* @param Collection $users 사용자 목록
* @return array{posts: int, replies: int, comments: int} 생성 통계
*/
private function createBadgeCasePosts(Board $board, $users): array
@@ -848,7 +939,7 @@ class PostSampleSeeder extends Seeder
* notice는 관리자만, 나머지는 일반 사용자 + 관리자/스텝 혼합
*
* @param Board $board 게시판
* @param \Illuminate\Support\Collection $users 사용자 목록
* @param Collection $users 사용자 목록
* @param int $index 현재 인덱스
* @return User 작성자
*/
@@ -879,8 +970,8 @@ class PostSampleSeeder extends Seeder
* @param string|null $category 카테고리
* @param array $template 콘텐츠 템플릿
* @param bool $isSecret 비밀글 여부
* @param \Carbon\Carbon $createdAt 원본 게시글 생성일
* @param \Illuminate\Support\Collection $users 사용자 목록
* @param Carbon $createdAt 원본 게시글 생성일
* @param Collection $users 사용자 목록
* @return int 생성된 답변글 수
*/
private function createRepliesForPost(
@@ -889,7 +980,7 @@ class PostSampleSeeder extends Seeder
?string $category,
array $template,
bool $isSecret,
\Carbon\Carbon $createdAt,
Carbon $createdAt,
$users
): int {
// inquiry 게시판은 95% 확률, 나머지는 30% 확률
@@ -906,7 +997,9 @@ class PostSampleSeeder extends Seeder
$replyUser = ($board->slug === 'inquiry')
? ($this->stepUser ?? $this->adminUser ?? $users[count($users) - 1])
: ($this->adminUser ?? $users[count($users) - 1]);
$replyCreatedAt = $createdAt->copy()->addHours(rand(1, 72));
// 부모 글 시각 + (1~72시간) 으로 자식 시각을 미루되, 현재 시각을 절대 넘기지 않는다.
// 부모 글이 "오늘 분포" 로 만들어졌을 때 자식이 미래로 밀려 대시보드/목록의 created_at 정렬이 뒤집히는 회귀 방지.
$replyCreatedAt = $this->clampToNow($createdAt->copy()->addHours(rand(1, 72)));
$answerTemplate = self::ANSWER_TEMPLATES[array_rand(self::ANSWER_TEMPLATES)];
$replyId = DB::table('board_posts')->insertGetId([
@@ -944,7 +1037,7 @@ class PostSampleSeeder extends Seeder
? $users[rand(0, count($users) - 1)]
: ($this->stepUser ?? $this->adminUser ?? $users[count($users) - 1]);
$depthCreatedAt = $replyCreatedAt->copy()->addHours(rand(1, 48));
$depthCreatedAt = $this->clampToNow($replyCreatedAt->copy()->addHours(rand(1, 48)));
$nestedTemplates = self::NESTED_ANSWER_TEMPLATES[$depth] ?? self::NESTED_ANSWER_TEMPLATES[3] ?? self::ANSWER_TEMPLATES;
$nestedContent = $nestedTemplates[array_rand($nestedTemplates)];
@@ -985,8 +1078,8 @@ class PostSampleSeeder extends Seeder
*
* @param Board $board 게시판
* @param int $postId 게시글 ID
* @param \Carbon\Carbon $createdAt 게시글 생성일
* @param \Illuminate\Support\Collection $users 사용자 목록
* @param Carbon $createdAt 게시글 생성일
* @param Collection $users 사용자 목록
* @param int $postIndex 게시글 인덱스 (날짜 분포 결정용)
* @param int $todayCount 오늘 게시글 수
* @param int $weekCount 이번주 게시글 수
@@ -997,7 +1090,7 @@ class PostSampleSeeder extends Seeder
private function createCommentsForPost(
Board $board,
int $postId,
\Carbon\Carbon $createdAt,
Carbon $createdAt,
$users,
int $postIndex,
int $todayCount,
@@ -1023,8 +1116,8 @@ class PostSampleSeeder extends Seeder
// 작성자: 일부는 관리자/스텝
$commentUser = $this->pickCommentAuthor($users);
// 댓글은 게시글보다 0~7일 후 작성
$commentCreatedAt = $createdAt->copy()->addHours(rand(1, 168));
// 댓글은 게시글보다 0~7일 후 작성하되, 현재 시각을 넘지 않도록 clamp.
$commentCreatedAt = $this->clampToNow($createdAt->copy()->addHours(rand(1, 168)));
$commentId = DB::table('board_comments')->insertGetId([
'board_id' => $board->id,
@@ -1062,8 +1155,8 @@ class PostSampleSeeder extends Seeder
* @param Board $board 게시판
* @param int $postId 게시글 ID
* @param int $parentCommentId 부모 댓글 ID
* @param \Carbon\Carbon $parentCreatedAt 부모 댓글 생성일
* @param \Illuminate\Support\Collection $users 사용자 목록
* @param Carbon $parentCreatedAt 부모 댓글 생성일
* @param Collection $users 사용자 목록
* @param int $maxDepth 최대 depth
* @return int 생성된 대댓글 수
*/
@@ -1071,7 +1164,7 @@ class PostSampleSeeder extends Seeder
Board $board,
int $postId,
int $parentCommentId,
\Carbon\Carbon $parentCreatedAt,
Carbon $parentCreatedAt,
$users,
int $maxDepth
): int {
@@ -1093,7 +1186,7 @@ class PostSampleSeeder extends Seeder
}
$replyUser = $this->pickCommentAuthor($users);
$replyCreatedAt = $currentCreatedAt->copy()->addHours(rand(1, 48));
$replyCreatedAt = $this->clampToNow($currentCreatedAt->copy()->addHours(rand(1, 48)));
// depth별 템플릿 선택
$templates = self::NESTED_REPLY_TEMPLATES[$depth] ?? self::NESTED_REPLY_TEMPLATES[3] ?? self::NESTED_REPLY_TEMPLATES[1];
@@ -1129,7 +1222,7 @@ class PostSampleSeeder extends Seeder
*
* 8% 관리자, 5% 스텝, 나머지 일반 사용자
*
* @param \Illuminate\Support\Collection $users 사용자 목록
* @param Collection $users 사용자 목록
* @return User 작성자
*/
private function pickCommentAuthor($users): User
@@ -1177,9 +1270,9 @@ class PostSampleSeeder extends Seeder
* @param int $todayCount 오늘 게시글 수 (방금~24시간 전)
* @param int $weekCount 이번주 게시글 수 (1~7일 전)
* @param int $monthCount 이번달 게시글 수 (8~30일 전)
* @return \Carbon\Carbon 생성일
* @return Carbon 생성일
*/
private function generateCreatedAt(int $index, int $todayCount, int $weekCount, int $monthCount): \Carbon\Carbon
private function generateCreatedAt(int $index, int $todayCount, int $weekCount, int $monthCount): Carbon
{
if ($index < $todayCount) {
// 오늘: 방금 전 ~ 23시간 전 (모든 분/시간 구간 커버)
@@ -1211,6 +1304,23 @@ class PostSampleSeeder extends Seeder
}
}
/**
* Carbon 시각이 현재 시각을 넘으면 현재 시각으로 clamp 합니다.
*
* 답글/댓글 시드가 부모의 created_at + addHours() 로 자식 시각을 미루는 구조라
* 부모가 "오늘" 분포로 만들어진 경우 자식이 미래로 밀려 들어가 대시보드/목록 정렬을 뒤집는 회귀가 생긴다.
* 이 헬퍼로 모든 자식 시각의 상한을 now() 로 잡아 미래 created_at 발생 자체를 차단한다.
*
* @param Carbon $time 계산된 자식 시각
* @return Carbon 현재 시각 이하의 시각
*/
private function clampToNow(Carbon $time): Carbon
{
$now = now();
return $time->greaterThan($now) ? $now : $time;
}
/**
* 게시판이 비회원 글쓰기를 허용하는지 확인합니다.
*
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -5,13 +5,13 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.0.0-beta.5",
"version": "1.0.0",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
"g7_version": ">=7.0.0-beta.5",
"g7_version": ">=7.0.0",
"dependencies": {
"modules": {},
"plugins": {}
+62
View File
@@ -50,6 +50,24 @@ class Module extends AbstractModule
return parent::uninstall();
}
/**
* 모듈 스케줄 목록 반환
*
* 대시보드 게시물 현황 집계를 매시간 실행합니다.
*
* @return array<int, array<string, string>> 스케줄 정의 목록
*/
public function getSchedules(): array
{
return [
[
'command' => 'sirsoft-board:aggregate-stats',
'schedule' => 'hourly',
'description' => '대시보드 게시물 현황 집계',
],
];
}
/**
* 모듈 권한 목록 반환 (계층형 구조, 다국어 지원)
*
@@ -562,6 +580,50 @@ class Module extends AbstractModule
return $schema;
}
/**
* OG 기본값 키별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
*
* seoOgDefaults() 가 resolve 해 반환하는 평문값이 **어느 게시글 데이터에서 왔는지**를 편집기
* [검색엔진] 탭이 "대표 이미지"·"게시글 제목" 같은 연결 칩으로 보여주고 교체할 수 있도록,
* 키별 데이터 경로(표현식)와 사용자용 라벨을 제공합니다. 단순 1:1 경로 키만 선언합니다.
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: array<string, string>}> 키별 데이터 경로 메타
*/
public function seoOgDefaultMeta(string $pageType): array
{
// label 은 번역 키 — 번들 언어팩(ja 등)이 같은 키를 번역하면 추가 언어에 자동 대응(편집기가 __() 해석).
if ($pageType === 'post') {
return [
'image' => ['expr' => '{{post.data.thumbnail}}', 'label' => 'sirsoft-board::seo.auto_value.post_image'],
'image_alt' => ['expr' => '{{post.data.subject}}', 'label' => 'sirsoft-board::seo.auto_value.post_title'],
];
}
return [];
}
/**
* 구조화 데이터 속성별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
*
* seoStructuredData() 의 Article 스키마를 점 경로 키로 평탄화한 기준으로 선언합니다. 파생값
* (description=strip_tags·datePublished·author.* 등)은 단순 경로가 아니라 제외합니다(평문 폴백).
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: array<string, string>}> 점 경로 키별 데이터 경로 메타
*/
public function seoStructuredDataMeta(string $pageType): array
{
if ($pageType !== 'post') {
return [];
}
return [
'headline' => ['expr' => '{{post.data.subject}}', 'label' => 'sirsoft-board::seo.auto_value.post_title'],
'image' => ['expr' => '{{post.data.thumbnail}}', 'label' => 'sirsoft-board::seo.auto_value.post_image'],
];
}
/**
* 스토리지 디스크 설정 반환
*/
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.0-beta.4",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
"version": "1.0.0-beta.4",
"version": "1.0.0",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.0-beta.5",
"version": "1.0.0",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -17,4 +17,4 @@
"vite": "^5.4.14",
"vitest": "^2.1.8"
}
}
}
@@ -2,6 +2,7 @@
"data_sources": [
{
"id": "board_list_for_inquiry",
"label_key": "$t:sirsoft-board.editor.data_source.board_list_for_inquiry",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards",
"method": "GET",
@@ -29,7 +30,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
"className": "text-body"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.description"
},
@@ -38,29 +39,35 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-1.5"
"className": "row-stack"
},
"children": [
{
"id": "inquiry_board_label",
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.board_label"
},
{
"id": "inquiry_board_select",
"type": "basic",
"name": "Select",
"props": {
"name": "inquiry.board_slug",
"value": "{{_local.form?.inquiry?.board_slug ?? ''}}",
"options": "{{[{value: '', label: $t('sirsoft-board.admin.ecommerce_settings.inquiry.board_placeholder')}].concat((board_list_for_inquiry?.data?.data ?? []).map(b => ({value: b.slug, label: b.name})))}}",
"className": "{{_local.errors?.['inquiry.board_slug'] ? 'input input-error' : 'input'}}",
"disabled": "{{_computed.isReadOnly}}"
}
"name": "Div",
"children": [
{
"id": "inquiry_board_label",
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.board_label"
},
{
"id": "inquiry_board_select",
"type": "basic",
"name": "Select",
"props": {
"name": "inquiry.board_slug",
"value": "{{_local.form?.inquiry?.board_slug ?? ''}}",
"options": "{{[{value: '', label: $t('sirsoft-board.admin.ecommerce_settings.inquiry.board_placeholder')}].concat((board_list_for_inquiry?.data?.data ?? []).map(b => ({value: b.slug, label: b.name})))}}",
"className": "{{_local.errors?.['inquiry.board_slug'] ? 'input input-error' : 'input'}}",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
},
{
"id": "inquiry_board_select_error",
@@ -68,7 +75,7 @@
"name": "Span",
"if": "{{_local.errors?.['inquiry.board_slug']}}",
"props": {
"className": "text-red-500 text-xs mt-1 block"
"className": "form-error"
},
"text": "{{_local.errors?.['inquiry.board_slug']?.[0] ?? ''}}"
}
@@ -87,7 +94,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "text-tertiary"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.hint_secret"
},
@@ -96,7 +103,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "text-tertiary"
},
"text": "$t:sirsoft-board.admin.ecommerce_settings.inquiry.hint_category"
},
@@ -106,7 +113,7 @@
"name": "Div",
"if": "{{!!(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}",
"props": {
"className": "flex flex-col md:flex-row gap-3"
"className": "flex flex-col gap-2 md:flex-row"
},
"children": [
{
@@ -121,13 +128,16 @@
"type": "basic",
"name": "P",
"props": {
"className": "flex items-start gap-1.5 text-xs text-gray-500 dark:text-gray-400"
"className": "flex-start gap-1.5 text-tertiary"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "gear", "className": "w-3 h-3 shrink-0 mt-0.5" }
"props": {
"name": "gear",
"className": "w-3 h-3 shrink-0 mt-0.5"
}
},
{
"type": "basic",
@@ -148,7 +158,10 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "pen-to-square", "className": "w-3 h-3" }
"props": {
"name": "pen-to-square",
"className": "w-3 h-3"
}
},
{
"type": "basic",
@@ -180,13 +193,16 @@
"type": "basic",
"name": "P",
"props": {
"className": "flex items-start gap-1.5 text-xs text-gray-500 dark:text-gray-400"
"className": "flex-start gap-1.5 text-tertiary"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "clipboard-list", "className": "w-3 h-3 shrink-0 mt-0.5" }
"props": {
"name": "clipboard-list",
"className": "w-3 h-3 shrink-0 mt-0.5"
}
},
{
"type": "basic",
@@ -214,7 +230,10 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "list", "className": "w-3 h-3" }
"props": {
"name": "list",
"className": "w-3 h-3"
}
},
{
"type": "basic",
@@ -244,7 +263,10 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "plus", "className": "w-3 h-3" }
"props": {
"name": "plus",
"className": "w-3 h-3"
}
},
{
"type": "basic",
@@ -257,22 +279,33 @@
"type": "click",
"handler": "apiCall",
"target": "/api/modules/sirsoft-board/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/add-to-menu",
"params": { "method": "POST" },
"params": {
"method": "POST"
},
"auth_required": true,
"onSuccess": [
{
"handler": "toast",
"params": { "type": "success", "message": "{{result.message}}" }
"params": {
"type": "success",
"message": "{{result.message}}"
}
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "admin_menu", "sync": true }
"params": {
"dataSourceId": "admin_menu",
"sync": true
}
}
],
"onError": [
{
"handler": "toast",
"params": { "type": "error", "message": "{{error.message}}" }
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
@@ -0,0 +1,641 @@
{
"extension_point": "admin_dashboard_community",
"mode": "append",
"priority": 100,
"data_sources": [
{
"id": "board_overview",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/dashboard/overview",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"fallback": {
"data": {
"today_posts": 0,
"today_comments": 0
}
}
},
{
"id": "board_post_graph",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/dashboard/post-graph",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"fallback": {
"data": {
"days": [],
"total_posts": 0,
"total_comments": 0,
"posts_change": null,
"comments_change": null,
"updated_at": null,
"updated_at_display": ""
}
}
},
{
"id": "board_recent_posts",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/dashboard/recent-posts",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"fallback": {
"data": {
"data": []
}
}
},
{
"id": "board_pending_reports",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/dashboard/pending-reports",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"fallback": {
"data": {
"items": [],
"total": 0
}
}
}
],
"components": [
{
"id": "community_section_wrapper",
"type": "basic",
"name": "Div",
"props": {
"className": "mb-4 md:mb-6 lg:mb-8"
},
"children": [
{
"id": "community_section_grid",
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 lg:gap-5"
},
"children": [
{
"id": "post_graph_today_summary",
"type": "basic",
"name": "Div",
"blur_until_loaded": {
"enabled": true,
"data_sources": "board_overview"
},
"props": {
"className": "flex-center flex-wrap gap-2 md:col-span-2 lg:col-span-4"
},
"children": [
{
"id": "post_graph_today_label",
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-semibold text-slate-500 dark:text-slate-400"
},
"text": "$t:admin.dashboard.stats.today"
},
{
"id": "today_new_posts_badge",
"type": "basic",
"name": "Span",
"props": {
"className": "px-2 py-1 text-xs font-medium rounded-full bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200 cursor-pointer hover:bg-slate-300 dark:hover:bg-slate-600 transition-colors"
},
"text": "$t:sirsoft-board.admin.dashboard.today_badges.new_posts|count={{board_overview?.data?.today_posts ?? 0}}",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards"
}
}
]
},
{
"id": "today_new_comments_badge",
"type": "basic",
"name": "Span",
"props": {
"className": "px-2 py-1 text-xs font-medium rounded-full bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200 cursor-pointer hover:bg-slate-300 dark:hover:bg-slate-600 transition-colors"
},
"text": "$t:sirsoft-board.admin.dashboard.today_badges.new_comments|count={{board_overview?.data?.today_comments ?? 0}}",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards"
}
}
]
},
{
"id": "community_section_badge",
"type": "composite",
"name": "ExtensionBadge",
"props": {
"className": "ml-auto",
"type": "module",
"identifier": "sirsoft-board",
"installedModules": "{{_global.installedModules}}"
}
}
]
},
{
"id": "post_graph_card",
"type": "basic",
"name": "Div",
"blur_until_loaded": {
"enabled": true,
"data_sources": [
"board_overview",
"board_post_graph"
]
},
"props": {
"className": "bg-slate-100 dark:bg-slate-800 p-4 md:p-6 rounded-xl md:rounded-2xl md:col-span-2 h-full flex flex-col"
},
"children": [
{
"id": "post_graph_header",
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-2 md:flex-row md:items-center md:justify-between mb-4 md:mb-6"
},
"children": [
{
"id": "post_graph_title",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-slate-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.dashboard.community.post_graph"
},
{
"id": "post_graph_legend",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-4"
},
"children": [
{
"id": "posts_legend_item",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"id": "posts_legend_dot",
"type": "basic",
"name": "Span",
"props": {
"className": "w-3 h-3 rounded-full bg-violet-500"
}
},
{
"id": "posts_legend_label",
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-slate-500 dark:text-slate-400"
},
"text": "$t:sirsoft-board.admin.dashboard.community.posts"
}
]
},
{
"id": "comments_legend_item",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"id": "comments_legend_dot",
"type": "basic",
"name": "Span",
"props": {
"className": "w-3 h-3 rounded-full bg-violet-200"
}
},
{
"id": "comments_legend_label",
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-slate-500 dark:text-slate-400"
},
"text": "$t:sirsoft-board.admin.dashboard.community.comments"
}
]
}
]
}
]
},
{
"id": "post_graph_stats",
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-4 md:gap-8 mb-4 md:mb-6"
},
"children": [
{
"id": "total_posts_stat",
"type": "basic",
"name": "Div",
"children": [
{
"id": "total_posts_label",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-slate-500 dark:text-slate-400 mb-1"
},
"text": "$t:sirsoft-board.admin.dashboard.community.total_posts"
},
{
"id": "total_posts_value_wrapper",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2"
},
"children": [
{
"id": "total_posts_value",
"type": "basic",
"name": "Span",
"props": {
"className": "text-lg md:text-2xl font-bold text-slate-900 dark:text-white"
},
"text": "{{board_post_graph?.data?.total_posts ?? 0 | number}}"
},
{
"id": "total_posts_change",
"type": "basic",
"name": "Span",
"props": {
"className": "{{(board_post_graph?.data?.posts_change ?? null) === null ? 'text-xs font-medium text-slate-400' : ((board_post_graph?.data?.posts_change ?? 0) >= 0 ? 'text-xs font-medium text-emerald-500' : 'text-xs font-medium text-rose-500')}}"
},
"text": "{{(board_post_graph?.data?.posts_change ?? null) === null ? '—' : ((board_post_graph?.data?.posts_change >= 0 ? '+' : '') + board_post_graph?.data?.posts_change + '%')}}"
}
]
}
]
},
{
"id": "total_comments_stat",
"type": "basic",
"name": "Div",
"children": [
{
"id": "total_comments_label",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-slate-500 dark:text-slate-400 mb-1"
},
"text": "$t:sirsoft-board.admin.dashboard.community.total_comments"
},
{
"id": "total_comments_value_wrapper",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-2"
},
"children": [
{
"id": "total_comments_value",
"type": "basic",
"name": "Span",
"props": {
"className": "text-lg md:text-2xl font-bold text-slate-900 dark:text-white"
},
"text": "{{board_post_graph?.data?.total_comments ?? 0 | number}}"
},
{
"id": "total_comments_change",
"type": "basic",
"name": "Span",
"props": {
"className": "{{(board_post_graph?.data?.comments_change ?? null) === null ? 'text-xs font-medium text-slate-400' : ((board_post_graph?.data?.comments_change ?? 0) >= 0 ? 'text-xs font-medium text-emerald-500' : 'text-xs font-medium text-rose-500')}}"
},
"text": "{{(board_post_graph?.data?.comments_change ?? null) === null ? '—' : ((board_post_graph?.data?.comments_change >= 0 ? '+' : '') + board_post_graph?.data?.comments_change + '%')}}"
}
]
}
]
}
]
},
{
"id": "post_graph_chart",
"type": "composite",
"name": "BarChart",
"props": {
"labels": "{{(board_post_graph?.data?.days ?? []).map(d => (d.date ?? '').slice(5).replace('-', '/'))}}",
"datasets": "{{[{label: $t('sirsoft-board.admin.dashboard.community.posts'), data: (board_post_graph?.data?.days ?? []).map(d => d.post_count ?? 0), backgroundColor: '#7C3AED'}, {label: $t('sirsoft-board.admin.dashboard.community.comments'), data: (board_post_graph?.data?.days ?? []).map(d => d.comment_count ?? 0), backgroundColor: '#DDD6FE'}]}}",
"height": 180,
"showYGrid": true,
"showYAxis": true
}
},
{
"id": "post_graph_updated_at_caption",
"type": "basic",
"name": "P",
"if": "{{!!board_post_graph?.data?.updated_at_display}}",
"props": {
"className": "text-xs text-slate-400 dark:text-slate-500 mt-2 text-right"
},
"text": "$t:sirsoft-board.admin.dashboard.community.updated_at|time={{board_post_graph?.data?.updated_at_display ?? ''}}"
}
]
},
{
"id": "latest_posts_card",
"type": "basic",
"name": "Div",
"blur_until_loaded": {
"enabled": true,
"data_sources": "board_recent_posts"
},
"props": {
"className": "bg-slate-100 dark:bg-slate-800 p-4 md:p-6 rounded-xl md:rounded-2xl h-full flex flex-col"
},
"children": [
{
"id": "latest_posts_header",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between mb-4"
},
"children": [
{
"id": "latest_posts_title",
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-slate-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.dashboard.community.latest_posts"
},
{
"id": "latest_posts_view_all",
"type": "basic",
"name": "A",
"props": {
"href": "/admin/boards",
"className": "text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
},
"text": "$t:sirsoft-board.admin.dashboard.community.go_to_boards"
}
]
},
{
"id": "latest_posts_empty",
"type": "basic",
"name": "P",
"if": "{{(board_recent_posts?.data ?? []).length === 0}}",
"props": {
"className": "text-sm text-slate-500 dark:text-slate-400 text-center py-6"
},
"text": "$t:sirsoft-board.admin.dashboard.community.no_posts"
},
{
"id": "latest_posts_list",
"type": "basic",
"name": "Div",
"if": "{{(board_recent_posts?.data ?? []).length > 0}}",
"props": {
"className": "divide-y divide-slate-200 dark:divide-slate-700"
},
"children": [
{
"id": "latest_post_item_{{i}}",
"type": "basic",
"name": "Div",
"iteration": {
"source": "board_recent_posts?.data",
"item_var": "post",
"index_var": "i"
},
"props": {
"className": "py-3 min-h-12 flex flex-col justify-center cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{post?.board_slug}}/post/{{post?.id}}"
}
}
],
"children": [
{
"id": "latest_post_title_{{i}}",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm font-medium text-slate-900 dark:text-white truncate"
},
"text": "{{post?.title ?? ''}}"
},
{
"id": "latest_post_meta_{{i}}",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-slate-500 dark:text-slate-400"
},
"text": "{{(post?.board_name ?? '') + ' · ' + (post?.author_name ?? '') + ' · ' + (post?.created_at ?? '')}}"
}
]
}
]
}
]
},
{
"id": "report_management_card",
"type": "basic",
"name": "Div",
"blur_until_loaded": {
"enabled": true,
"data_sources": "board_pending_reports"
},
"props": {
"className": "bg-slate-100 dark:bg-slate-800 p-4 md:p-6 rounded-xl md:rounded-2xl h-full flex flex-col"
},
"children": [
{
"id": "report_management_header",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between mb-4"
},
"children": [
{
"id": "report_management_title_wrapper",
"type": "basic",
"name": "H3",
"props": {
"className": "flex-center text-base font-semibold text-slate-900 dark:text-white gap-1"
},
"children": [
{
"id": "report_management_title_text",
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.dashboard.community.report_management"
},
{
"id": "report_management_count",
"type": "basic",
"name": "Span",
"if": "{{(board_pending_reports?.data?.total ?? 0) > 0}}",
"props": {
"className": "text-purple-600 dark:text-purple-400"
},
"text": "({{board_pending_reports?.data?.total ?? 0}})"
}
]
},
{
"id": "report_management_view_all",
"type": "basic",
"name": "A",
"props": {
"href": "/admin/boards/reports",
"className": "text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
},
"text": "$t:sirsoft-board.admin.dashboard.community.go_to_reports"
}
]
},
{
"id": "report_management_empty",
"type": "basic",
"name": "P",
"if": "{{(board_pending_reports?.data?.items ?? []).length === 0}}",
"props": {
"className": "text-sm text-slate-500 dark:text-slate-400 text-center py-6"
},
"text": "$t:sirsoft-board.admin.dashboard.community.no_reports"
},
{
"id": "report_management_list",
"type": "basic",
"name": "Div",
"if": "{{(board_pending_reports?.data?.items ?? []).length > 0}}",
"props": {
"className": "divide-y divide-slate-200 dark:divide-slate-700"
},
"children": [
{
"id": "report_item_{{i}}",
"type": "basic",
"name": "Div",
"iteration": {
"source": "board_pending_reports?.data?.items",
"item_var": "report",
"index_var": "i"
},
"props": {
"className": "py-3 min-h-12 flex flex-col justify-center cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/reports/{{report?.id}}"
}
}
],
"children": [
{
"id": "report_header_{{i}}",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"id": "report_target_type_badge_{{i}}",
"type": "basic",
"name": "Span",
"props": {
"className": "{{report?.target_type === 'comment' ? 'shrink-0 px-2 text-xs font-medium rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'shrink-0 px-2 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'}}"
},
"text": "{{report?.target_type_label ?? ''}}"
},
{
"id": "report_title_line_{{i}}",
"type": "basic",
"name": "P",
"props": {
"className": "flex-1 min-w-0 text-sm font-medium text-slate-900 dark:text-white truncate"
},
"text": "{{report?.target_title ?? '(제목 없음)'}}"
},
{
"id": "report_status_badge_{{i}}",
"type": "basic",
"name": "Span",
"if": "{{!!report?.status_label}}",
"props": {
"className": "shrink-0 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{report?.status === 'pending' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' : report?.status === 'review' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : report?.status === 'rejected' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : report?.status === 'suspended' ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' : report?.status === 'deleted' ? 'bg-gray-600 text-white dark:bg-gray-600 dark:text-gray-100' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'}}"
},
"text": "{{report?.status_label ?? ''}}"
}
]
},
{
"id": "report_meta_{{i}}",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-slate-500 dark:text-slate-400"
},
"text": "{{(report?.board_name ?? '') + ' · ' + (report?.author_name ?? '익명') + ' · ' + (report?.last_reported_at ?? '')}}"
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,61 @@
{
"extension_point": "admin_dashboard_quick_menu",
"mode": "append",
"priority": 50,
"components": [
{
"id": "qm_boards",
"type": "basic",
"name": "A",
"props": {
"href": "/admin/boards",
"className": "flex-center flex-col justify-center gap-1 w-16 h-16 md:w-20 md:h-20 text-[10px] md:text-xs font-medium rounded-xl bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-700 hover:text-white dark:hover:bg-slate-200 dark:hover:text-slate-800 hover:-translate-y-0.5 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:focus:ring-slate-500 transition-all duration-200"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "clipboard-list",
"className": "text-lg md:text-xl"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-center leading-tight"
},
"text": "$t:sirsoft-board.admin.dashboard.quick_menu.boards"
}
]
},
{
"id": "qm_reports",
"type": "basic",
"name": "A",
"props": {
"href": "/admin/boards/reports",
"className": "flex-center flex-col justify-center gap-1 w-16 h-16 md:w-20 md:h-20 text-[10px] md:text-xs font-medium rounded-xl bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-700 hover:text-white dark:hover:bg-slate-200 dark:hover:text-slate-800 hover:-translate-y-0.5 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:focus:ring-slate-500 transition-all duration-200"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "flag",
"className": "text-lg md:text-xl"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-center leading-tight"
},
"text": "$t:sirsoft-board.admin.dashboard.quick_menu.reports"
}
]
}
]
}
@@ -34,21 +34,21 @@
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
"className": "admin-card"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4"
"className": "flex-between mb-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -56,14 +56,14 @@
"name": "Icon",
"props": {
"name": "bell",
"className": "w-5 h-5 text-gray-600 dark:text-gray-400"
"className": "text-secondary w-5 h-5"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
"className": "section-heading"
},
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
}
@@ -84,7 +84,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 gap-4"
"className": "grid-2col-responsive"
},
"children": [
{
@@ -96,7 +96,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
"className": "text-label-subtle block font-medium mb-1"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_complete"
},
@@ -104,7 +104,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
"className": "text-primary text-sm"
},
"text": "{{user?.data?.notify_post_complete ? '$t:admin.users.detail.enabled' : '$t:admin.users.detail.disabled'}}"
}
@@ -119,7 +119,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
"className": "text-label-subtle block font-medium mb-1"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_reply"
},
@@ -127,7 +127,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
"className": "text-primary text-sm"
},
"text": "{{user?.data?.notify_post_reply ? '$t:admin.users.detail.enabled' : '$t:admin.users.detail.disabled'}}"
}
@@ -142,7 +142,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
"className": "text-label-subtle block font-medium mb-1"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.comment"
},
@@ -150,7 +150,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
"className": "text-primary text-sm"
},
"text": "{{user?.data?.notify_comment ? '$t:admin.users.detail.enabled' : '$t:admin.users.detail.disabled'}}"
}
@@ -165,7 +165,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
"className": "text-label-subtle block font-medium mb-1"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.reply_comment"
},
@@ -173,7 +173,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-900 dark:text-white"
"className": "text-primary text-sm"
},
"text": "{{user?.data?.notify_reply_comment ? '$t:admin.users.detail.enabled' : '$t:admin.users.detail.disabled'}}"
}
@@ -2,198 +2,224 @@
"target_layout": "admin_user_form",
"injections": [
{
"target_id": "extension_slot",
"target_id": "user_form_tabs",
"position": "inject_props",
"props": {
"tabs": {
"_append": [
{
"id": "ext_notification",
"label": "$t:sirsoft-board.admin.users.form.sections.notification_settings",
"iconName": "cubes"
}
]
}
}
},
{
"target_id": "extension_form_content",
"position": "append_child",
"components": [
{
"id": "section_notification_settings",
"id": "ext_notification_form_content",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
"id": "ext_notification",
"className": "scroll-mt-32 mt-6"
},
"children": [
{
"id": "section_notification_settings",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-4"
"className": "admin-card"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-between mb-4"
},
"children": [
{
"type": "basic",
"name": "Icon",
"name": "Div",
"props": {
"name": "bell",
"className": "w-5 h-5 text-gray-600 dark:text-gray-400"
}
"className": "flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "bell",
"className": "text-secondary w-5 h-5"
}
},
{
"type": "basic",
"name": "H3",
"props": {
"className": "section-heading"
},
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
}
]
},
{
"type": "basic",
"name": "H3",
"type": "composite",
"name": "ExtensionBadge",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
"type": "module",
"identifier": "sirsoft-board",
"installedModules": "{{_global.installedModules}}"
}
}
]
},
{
"type": "composite",
"name": "ExtensionBadge",
"type": "basic",
"name": "Div",
"props": {
"type": "module",
"identifier": "sirsoft-board",
"installedModules": "{{_global.installedModules}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 gap-4"
},
"children": [
{
"id": "field_notify_post_complete",
"type": "basic",
"name": "Div",
"className": "grid-2col-responsive"
},
"children": [
{
"id": "field_notify_post_complete",
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"name": "Div",
"children": [
{
"type": "basic",
"name": "Input",
"name": "Label",
"props": {
"type": "checkbox",
"name": "notify_post_complete",
"checked": "{{_local.form?.notify_post_complete ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
"className": "flex-center gap-2 cursor-pointer"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_complete"
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_post_complete",
"checked": "{{_local.form?.notify_post_complete ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_complete"
}
]
}
]
}
]
},
{
"id": "field_notify_post_reply",
"type": "basic",
"name": "Div",
"children": [
},
{
"id": "field_notify_post_reply",
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"name": "Div",
"children": [
{
"type": "basic",
"name": "Input",
"name": "Label",
"props": {
"type": "checkbox",
"name": "notify_post_reply",
"checked": "{{_local.form?.notify_post_reply ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
"className": "flex-center gap-2 cursor-pointer"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_reply"
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_post_reply",
"checked": "{{_local.form?.notify_post_reply ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.post_reply"
}
]
}
]
}
]
},
{
"id": "field_notify_comment",
"type": "basic",
"name": "Div",
"children": [
},
{
"id": "field_notify_comment",
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"name": "Div",
"children": [
{
"type": "basic",
"name": "Input",
"name": "Label",
"props": {
"type": "checkbox",
"name": "notify_comment",
"checked": "{{_local.form?.notify_comment ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
"className": "flex-center gap-2 cursor-pointer"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.comment"
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_comment",
"checked": "{{_local.form?.notify_comment ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.comment"
}
]
}
]
}
]
},
{
"id": "field_notify_reply_comment",
"type": "basic",
"name": "Div",
"children": [
},
{
"id": "field_notify_reply_comment",
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-center gap-2 cursor-pointer"
},
"name": "Div",
"children": [
{
"type": "basic",
"name": "Input",
"name": "Label",
"props": {
"type": "checkbox",
"name": "notify_reply_comment",
"checked": "{{_local.form?.notify_reply_comment ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
"className": "flex-center gap-2 cursor-pointer"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.reply_comment"
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "notify_reply_comment",
"checked": "{{_local.form?.notify_reply_comment ?? false}}",
"className": "w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body"
},
"text": "$t:sirsoft-board.admin.users.form.notifications.reply_comment"
}
]
}
]
}
@@ -0,0 +1,84 @@
/**
* @file admin-attachment-download-handler.test.tsx
* @description 관리자 게시글/답변 첨부 다운로드 카드가 토큰 동반 핸들러로 다운로드하는지 회귀 (이슈 #413 item 58b)
*
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
*
* 결함: 관리자 게시글/답변 첨부 카드(id: "attachment_card")가
* <A href="{{attachment.download_url}}"> 브라우저 직접 링크였다. download_url 은 사용자
* 라우트(optional.sanctum)를 가리키며, <a> 네비게이션에는 토큰이 실리지 않아
* 활동이력 행위자(user_id)가 NULL 로 남았다(사용자 카드와 동일 원인).
*
* 수정: 카드를 Div + actions:[{click → custom:downloadAttachment}] 로 전환해
* 코어 ApiClient(G7Core.api.get)를 거쳐 관리자 회원 ID 가 활동이력에 기록되도록 한다.
*
* @scenario card=admin_post
* @scenario card=admin_reply
* @effects download_card_uses_handler_not_anchor
*/
import { describe, it, expect } from 'vitest';
import postCard from '../../../layouts/admin/partials/admin_board_post_detail/_post_card_content.json';
import replyCard from '../../../layouts/admin/partials/admin_board_post_detail/_reply_card_content.json';
type Node = Record<string, unknown> & { children?: unknown; actions?: unknown };
function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] {
const result: Node[] = [];
const walk = (cur: unknown): void => {
if (Array.isArray(cur)) {
cur.forEach(walk);
return;
}
if (cur && typeof cur === 'object') {
const obj = cur as Node;
if (predicate(obj)) {
result.push(obj);
}
Object.values(obj).forEach(walk);
}
};
walk(node);
return result;
}
function findAttachmentCards(root: unknown): Node[] {
return collectNodes(root, (n) => n.id === 'attachment_card');
}
// @scenario card=admin_post
// @scenario card=admin_reply
describe.each([
['게시글', postCard],
['답변', replyCard],
])('이슈 #413-58b — 관리자 %s 첨부 다운로드 핸들러 전환', (_label, layout) => {
it('첨부 카드는 <A href> 직접 링크가 아니다 (토큰 미동반 회귀 차단)', () => {
const cards = findAttachmentCards(layout);
expect(cards.length).toBeGreaterThan(0);
for (const card of cards) {
const props = (card.props ?? {}) as Record<string, unknown>;
const isAnchorHref = card.name === 'A' && typeof props.href === 'string';
expect(isAnchorHref).toBe(false);
}
});
it('첨부 카드는 click 액션으로 downloadAttachment 핸들러를 호출한다', () => {
const cards = findAttachmentCards(layout);
expect(cards.length).toBeGreaterThan(0);
const card = cards[0];
const actions = (card.actions ?? []) as Array<Record<string, unknown>>;
const clickAction = actions.find((a) => a.type === 'click');
expect(clickAction).toBeDefined();
// 짧은 이름으로 등록된 핸들러는 handler 에 핸들러명을 직접 둔다
// (redirectToLoginWithReturn 과 동일 형태). custom+name 은 prefix 풀네임 등록용.
expect(clickAction?.handler).toBe('downloadAttachment');
const params = (clickAction?.params ?? {}) as Record<string, unknown>;
expect(params.url).toContain('download_url');
expect(params.filename).toContain('original_filename');
});
});
@@ -3,7 +3,8 @@
*
* @description
* - 기본 탭(_tab_basic): use_comment/use_reply 토글 제거, secret_mode→show_view_count 순서
* - 게시글 탭(_tab_post): section_reply 분리, use_comment 서브필드 분리, condition→if 마이그레이션
* - 게시글 탭(_tab_post): admin-card → row-stack 직속 평면 구조, use_reply/use_comment/use_file_upload 토글 + 조건부 필드의 if 마이그레이션
* - 5 partial 모두 root id = `section_*_card` + row-stack 컨테이너 `section_*_fields` 패턴 (#408)
*/
import { describe, it, expect } from 'vitest';
@@ -12,6 +13,10 @@ import { describe, it, expect } from 'vitest';
import tabBasic from '../../../layouts/admin/partials/admin_board_form/_tab_basic.json';
import tabPost from '../../../layouts/admin/partials/admin_board_form/_tab_post.json';
import tabPermissions from '../../../layouts/admin/partials/admin_board_form/_tab_permissions.json';
import boardForm from '../../../layouts/admin/admin_board_form.json';
// 허용 확장자 안내 문구 회귀 검증용 ko lang
import koFormLang from '../../../lang/partial/ko/admin/form.json';
/**
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
@@ -138,7 +143,8 @@ function findConditionNodes(node: any): any[] {
describe('admin_board_form/_tab_basic.json', () => {
it('파일이 유효한 JSON 구조이다', () => {
expect(tabBasic).toBeDefined();
expect(tabBasic.id).toBe('tab_content_basic');
// #408: partial root id 패턴 = section_*_card (admin-card 자산 적용)
expect(tabBasic.id).toBe('section_basic_card');
});
it('기본 섹션 카드에 use_comment, use_reply 토글이 없다 (게시글 탭으로 분리됨)', () => {
@@ -149,11 +155,11 @@ describe('admin_board_form/_tab_basic.json', () => {
});
it('기본 탭에서 secret_mode가 show_view_count보다 앞에 위치한다', () => {
// section_basic_card 하위 children에서 순서 확인
const card = findById(tabBasic, 'section_basic_card');
expect(card).toBeDefined();
// #408: 필드는 section_basic_card 직속이 아닌 row-stack 컨테이너 section_basic_fields 하위
const fieldsContainer = findById(tabBasic, 'section_basic_fields');
expect(fieldsContainer).toBeDefined();
const children = card.children ?? [];
const children = fieldsContainer.children ?? [];
const secretIdx = children.findIndex((c: any) => c.id === 'field_secret_mode');
const viewCountIdx = children.findIndex((c: any) => c.id === 'field_show_view_count');
@@ -171,6 +177,111 @@ describe('admin_board_form/_tab_basic.json', () => {
const fields = findFormFields(tabBasic);
expect(fields).toContain('use_report');
});
// ── 이슈 #413-15: 관리자 메뉴 추가 토글 ──
it('add_to_menu 토글이 기본 탭에 있다 (생성/수정 공용)', () => {
const fields = findFormFields(tabBasic);
expect(fields).toContain('add_to_menu');
});
it('add_to_menu Toggle은 폼 자동바인딩이며 route.id 조건이 없다 (생성 모드에서도 노출)', () => {
const toggles = findByName(tabBasic, 'Toggle').filter(
(t: any) => t.props?.name === 'add_to_menu'
);
expect(toggles).toHaveLength(1);
// 생성/수정 공용이므로 route.id 조건부 렌더가 없어야 함
expect(toggles[0].if).toBeUndefined();
});
it('기존 수동 add-to-menu 버튼(apiCall)이 제거되었다 (토글로 대체)', () => {
function collectTargets(node: any): string[] {
const out: string[] = [];
if (!node) return out;
for (const action of node.actions ?? []) {
if (typeof action.target === 'string') out.push(action.target);
}
for (const child of node.children ?? []) out.push(...collectTargets(child));
return out;
}
const targets = collectTargets(tabBasic);
const addToMenuCalls = targets.filter((t) => t.includes('/add-to-menu'));
expect(addToMenuCalls).toHaveLength(0);
});
// 이슈 #413-19-3: 카테고리 삭제는 차단하지 않고 '미분류'로 유지(안내문 고지).
// 동작하지 않던 before_remove/check_category_usage/모달 잔재만 제거한다.
it('카테고리 TagInput에 미구현 before_remove/check_category_usage 액션이 없다', () => {
const serialized = JSON.stringify(tabBasic);
expect(serialized).not.toContain('before_remove');
expect(serialized).not.toContain('check_category_usage');
expect(serialized).not.toContain('showCategoryRemoveModal');
});
it('카테고리 필드에 서버 검증 에러 인라인 표시(_local.errors.categories)가 있다', () => {
const serialized = JSON.stringify(tabBasic);
expect(serialized).toContain('_local.errors?.categories');
});
it('카테고리 안내문(categories_notice)이 존재한다', () => {
const notice = findById(tabBasic, 'field_categories_notice');
expect(notice).toBeDefined();
expect(notice.text).toContain('categories_notice');
});
// ── 이슈 #413-24: 제한 키워드(blocked_keywords) 게시글 설정 탭 소속 + TagInput 통일 ──
// @scenario field=blocked_keywords, tab=basic
// @effects blocked_keywords_absent_from_basic_tab
it('제한 키워드(blocked_keywords) 필드는 기본 탭에 없다 (게시글 설정 탭 소속)', () => {
const field = findById(tabBasic, 'field_blocked_keywords');
expect(field).toBeNull();
const serialized = JSON.stringify(tabBasic);
expect(serialized).not.toContain('blocked_keywords');
});
// @scenario field=allowed_extensions, tab=basic
// @effects allowed_extensions_absent_from_basic_tab
it('허용 확장자(allowed_extensions) 필드는 기본 탭에 없다 (게시글 탭 소속)', () => {
const input = findById(tabBasic, 'allowed_extensions_input');
expect(input).toBeNull();
const serialized = JSON.stringify(tabBasic);
expect(serialized).not.toContain('allowed_extensions');
});
});
// ============================================================
// admin_board_form.json — 푸터 취소 버튼 (이슈 #413-15 후속)
// ============================================================
describe('admin_board_form.json 취소 버튼', () => {
it('취소 버튼은 navigateBack 이 아니라 navigate 로 게시판 목록(/admin/boards)으로 이동한다', () => {
const cancelBtn = findById(boardForm, 'footer_cancel_button');
expect(cancelBtn).toBeDefined();
const actions = cancelBtn.actions ?? [];
expect(actions.length).toBeGreaterThan(0);
const clickAction = actions.find((a: any) => a.type === 'click');
expect(clickAction).toBeDefined();
// navigateBack(브라우저 뒤로가기)은 직접 진입 시 목록으로 가지 않으므로 금지
expect(clickAction.handler).toBe('navigate');
expect(clickAction.handler).not.toBe('navigateBack');
expect(clickAction.params?.path).toBe('/admin/boards');
});
// 이슈 #413-19-3: 동작하지 않던 category_remove 모달 제거
it('미구현 category_remove 모달이 제거되었다', () => {
const modals = (boardForm as any).modals ?? [];
const categoryModal = modals.find((m: any) => m.id === 'category_remove_modal');
expect(categoryModal).toBeUndefined();
});
it('미사용 showCategoryRemoveModal state가 제거되었다', () => {
const state = (boardForm as any).state ?? {};
expect(state).not.toHaveProperty('showCategoryRemoveModal');
expect(state).not.toHaveProperty('categoryToRemove');
});
});
// ============================================================
@@ -180,7 +291,8 @@ describe('admin_board_form/_tab_basic.json', () => {
describe('admin_board_form/_tab_post.json', () => {
it('파일이 유효한 JSON 구조이다', () => {
expect(tabPost).toBeDefined();
expect(tabPost.id).toBe('tab_content_post');
// #408: partial root id 패턴 = section_*_card (admin-card 자산 적용)
expect(tabPost.id).toBe('section_post_card');
});
it('게시글 탭에 deprecated condition 속성이 없다 (if로 마이그레이션 완료)', () => {
@@ -188,42 +300,35 @@ describe('admin_board_form/_tab_post.json', () => {
expect(conditionNodes, 'condition 속성 노드가 남아있음 (if로 교체 필요)').toHaveLength(0);
});
it('답변글 서브섹션(post_reply_section)이 존재한다', () => {
const replySection = findById(tabPost, 'post_reply_section');
expect(replySection).toBeDefined();
});
it('답변글 서브섹션에 use_reply 토글과 max_reply_depth 필드가 있다', () => {
const replySection = findById(tabPost, 'post_reply_section');
expect(replySection).toBeDefined();
const fields = findFormFields(replySection);
it('row-stack 컨테이너 section_post_fields 에 use_reply 토글과 max_reply_depth 필드가 있다', () => {
const fieldsContainer = findById(tabPost, 'section_post_fields');
expect(fieldsContainer).toBeDefined();
const fields = findFormFields(fieldsContainer);
expect(fields).toContain('use_reply');
expect(fields).toContain('max_reply_depth');
});
it('max_reply_depth 서브필드가 use_reply 토글 ON 시에만 표시된다 (if 조건)', () => {
// max_reply_depth_field Div 컨테이너에 if 조건이 적용됨
it('max_reply_depth_field 가 use_reply 토글 ON 시에만 표시된다 (if 조건)', () => {
// 평면화 후 max_reply_depth_field 가 row-stack 직속 자식으로 if 조건을 직접 보유
const maxReplyDepthField = findById(tabPost, 'max_reply_depth_field');
expect(maxReplyDepthField).toBeDefined();
expect(maxReplyDepthField.if).toBeDefined();
expect(maxReplyDepthField.if).toContain('use_reply');
});
it('댓글 서브섹션(post_comment_section)이 존재하고 use_comment 토글을 포함한다', () => {
const commentSection = findById(tabPost, 'post_comment_section');
expect(commentSection).toBeDefined();
const fields = findFormFields(commentSection);
it('row-stack 컨테이너 section_post_fields 에 use_comment 토글이 있다', () => {
const fieldsContainer = findById(tabPost, 'section_post_fields');
expect(fieldsContainer).toBeDefined();
const fields = findFormFields(fieldsContainer);
expect(fields).toContain('use_comment');
});
it('댓글 서브 필드들이 use_comment ON 시에만 표시된다 (if 조건)', () => {
// use_comment_sub_fields 컨테이너에 if 조건이 적용됨
// 평면화 후 post_comment_length_group / max_comment_depth_field / comment_order_field 모두 각자 if 보유
const card = findById(tabPost, 'section_post_card');
expect(card).toBeDefined();
// use_comment를 참조하는 if 속성을 가진 Div 컨테이너 확인
function findIfContaining(node: any, keyword: string): any[] {
const results: any[] = [];
if (node?.if?.includes(keyword)) results.push(node);
@@ -232,26 +337,85 @@ describe('admin_board_form/_tab_post.json', () => {
}
const useCommentConditionals = findIfContaining(card, 'use_comment');
expect(useCommentConditionals.length).toBeGreaterThan(0);
// post_comment_length_group / max_comment_depth_field / comment_order_field 3 개
expect(useCommentConditionals.length).toBeGreaterThanOrEqual(3);
});
it('post_reply_section이 post_comment_section보다 앞에 위치한다', () => {
// section_post_card 하위 children에서 순서 확인
const card = findById(tabPost, 'section_post_card');
expect(card).toBeDefined();
const children = card.children ?? [];
const replyIdx = children.findIndex((c: any) => c.id === 'post_reply_section');
const commentIdx = children.findIndex((c: any) => c.id === 'post_comment_section');
it('row-stack 직속 자식 순서: 답변글 토글이 댓글 토글보다 앞에 위치한다', () => {
// 평면화 후 sub-section wrapper 가 사라졌으므로 토글 자체의 순서로 검증
const fieldsContainer = findById(tabPost, 'section_post_fields');
expect(fieldsContainer).toBeDefined();
const children = fieldsContainer.children ?? [];
const replyToggleIdx = children.findIndex((c: any) => c.id === 'use_reply_toggle');
const commentToggleIdx = children.findIndex((c: any) => c.id === 'use_comment_toggle');
expect(replyIdx).toBeGreaterThanOrEqual(0);
expect(commentIdx).toBeGreaterThanOrEqual(0);
expect(replyIdx).toBeLessThan(commentIdx);
expect(replyToggleIdx).toBeGreaterThanOrEqual(0);
expect(commentToggleIdx).toBeGreaterThanOrEqual(0);
expect(replyToggleIdx).toBeLessThan(commentToggleIdx);
});
it('게시글 탭에 use_file_upload 토글이 있다', () => {
const fields = findFormFields(tabPost);
expect(fields).toContain('use_file_upload');
});
it('첨부파일 서브 필드들이 use_file_upload ON 시에만 표시된다 (if 조건)', () => {
// 평면화 후 file_settings_group / allowed_extensions_field 모두 각자 if 보유
const card = findById(tabPost, 'section_post_card');
expect(card).toBeDefined();
function findIfContaining(node: any, keyword: string): any[] {
const results: any[] = [];
if (node?.if?.includes(keyword)) results.push(node);
for (const c of node?.children ?? []) results.push(...findIfContaining(c, keyword));
return results;
}
const useFileConditionals = findIfContaining(card, 'use_file_upload');
// file_settings_group / allowed_extensions_field 2 개
expect(useFileConditionals.length).toBeGreaterThanOrEqual(2);
});
// ── 이슈 #413-24: 제한 키워드 게시글 설정 탭 소속 + TagInput 통일 ──
// @scenario field=blocked_keywords, tab=post
// @effects blocked_keywords_renders_as_taginput_on_post_tab
it('제한 키워드(blocked_keywords) 입력이 게시글 탭에 TagInput(배열)으로 있다 (Textarea 아님)', () => {
const input = findById(tabPost, 'blocked_keywords_input');
expect(input).toBeDefined();
expect(input.name).toBe('TagInput');
expect(input.props?.name).toBe('blocked_keywords');
expect(input.props?.creatable).toBe(true);
});
it('제한 키워드 필드에 서버 검증 에러 인라인 표시(_local.errors.blocked_keywords)가 있다', () => {
const serialized = JSON.stringify(tabPost);
expect(serialized).toContain('_local.errors?.blocked_keywords');
});
// @scenario field=allowed_extensions, tab=post
// @effects allowed_extensions_renders_as_taginput_on_post_tab
it('허용 확장자(allowed_extensions) 입력이 TagInput(배열)이다 (Input 아님)', () => {
const input = findById(tabPost, 'allowed_extensions_input');
expect(input).toBeDefined();
expect(input.name).toBe('TagInput');
expect(input.props?.name).toBe('allowed_extensions');
expect(input.props?.creatable).toBe(true);
});
it('허용 확장자 안내 문구가 description i18n 키를 참조한다 (회귀)', () => {
const descNode = findById(tabPost, 'allowed_extensions_description');
expect(descNode).toBeDefined();
expect(descNode.text).toBe('$t:sirsoft-board.admin.form.fields.allowed_extensions.description');
});
it('허용 확장자 안내 ko 문구가 "최소 1개" 의미로 갱신되었다 (회귀)', () => {
// 빈 값 저장 차단 정책에 맞춰 안내 문구도 "최소 1개 입력"이어야 하며,
// 정반대 의미의 옛 문구("빈 값 ... 모든 확장자")가 남아 있으면 안 된다.
const desc = (koFormLang as any).fields.allowed_extensions.description;
expect(desc).toContain('최소 1개');
expect(desc).not.toContain('모든 확장자');
});
});
// ============================================================
@@ -297,7 +461,8 @@ function getChangeActionFormField(tagInput: any, fieldName: string): string | nu
describe('admin_board_form/_tab_permissions.json', () => {
it('파일이 유효한 JSON 구조이다', () => {
expect(tabPermissions).toBeDefined();
expect((tabPermissions as any).id).toBe('tab_content_permissions');
// #408: partial root id 패턴 = section_*_card (admin-card 자산 적용)
expect((tabPermissions as any).id).toBe('section_permissions_card');
});
it('manager TagInput options 표현식이 u.id가 아닌 u.uuid를 value로 사용한다', () => {
@@ -111,4 +111,20 @@ describe('게시판 알림 템플릿 모달 회귀 (옵션 K)', () => {
expect(modalStr).toContain('"dataSourceId":"boardNotificationDefinitions"');
expect(modalStr).toContain('"board_notification_template_form_modal":null');
});
// 회귀: 역할/사용자 드롭다운이 저장된 선택값을 표시하지 못하던 버그.
// 검색 전(roleSearchResults/userSearchResults 빈 배열) 에도 현재 선택값을 options 에
// 시드해야 SearchableDropdown 이 라벨(rcpt.display_name / display_names) 을 표시한다.
it('role 드롭다운 options 가 현재 선택값을 시드', () => {
const modalStr = JSON.stringify(notifModal);
expect(modalStr).not.toContain('"options":"{{_global.board_notification_template_form_modal?.roleSearchResults ?? []}}"');
expect(modalStr).toContain('rcpt.display_name ?? rcpt.value');
expect(modalStr).toContain('String(o.value) !== String(rcpt.value)');
});
it('specific_users 드롭다운 options 가 현재 선택값을 시드', () => {
const modalStr = JSON.stringify(notifModal);
expect(modalStr).not.toContain('"options":"{{_global.board_notification_template_form_modal?.userSearchResults ?? []}}"');
expect(modalStr).toContain('(rcpt.display_names ?? [])[i] ?? uid');
});
});
@@ -28,6 +28,9 @@ import bulkApplyModal from '../../../layouts/admin/partials/admin_board_settings
import tabGeneral from '../../../layouts/admin/partials/admin_board_settings/_tab_general.json';
import tabSeo from '../../../layouts/admin/partials/admin_board_settings/_tab_seo.json';
// 허용 확장자 안내 문구 회귀 검증용 ko lang
import koSettingsLang from '../../../lang/partial/ko/admin/settings.json';
/**
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
*/
@@ -56,6 +59,23 @@ function findById(node: any, id: string): any | null {
return null;
}
/**
* JSON 트리에서 술어를 만족하는 첫 노드를 찾습니다 (children + responsive.portable 순회).
*/
function findFirst(node: any, predicate: (n: any) => boolean): any | null {
if (!node) return null;
if (predicate(node)) return node;
const kids = [
...(node.children ?? []),
...(node.responsive?.portable?.children ?? []),
];
for (const child of kids) {
const found = findFirst(child, predicate);
if (found) return found;
}
return null;
}
/**
* JSON 트리에서 특정 name을 가진 컴포넌트를 모두 찾습니다.
*/
@@ -273,7 +293,7 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
expect(tabAction.handler).toBe('sequence');
// sequence 하위 액션은 params.actions 에 위치 (엔진 규약)
const innerActions = tabAction.params.actions;
expect(innerActions).toHaveLength(3);
expect(innerActions).toHaveLength(4);
// setState - errors 초기화
expect(innerActions[0].handler).toBe('setState');
@@ -287,15 +307,20 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
// replaceUrl - URL 업데이트
expect(innerActions[2].handler).toBe('replaceUrl');
expect(innerActions[2].params.query.tab).toBeDefined();
// scrollIntoView - 탭 변경 후 본문 상단으로 스크롤 (#369)
expect(innerActions[3].handler).toBe('scrollIntoView');
// _admin_base 슬롯 #main_content 와 중복 회피 — 페이지 안쪽 탭 컨텐츠 컨테이너로 변경 (#408)
expect(innerActions[3].params.selector).toBe('#tab_content');
});
it('sticky 헤더 구조가 올바르다', () => {
const content = mainLayout.slots.content[0];
const stickyHeader = findById(content, 'sticky_header');
expect(stickyHeader).toBeDefined();
expect(stickyHeader.props.className).toContain('sticky');
expect(stickyHeader.props.className).toContain('top-0');
expect(stickyHeader.props.className).toContain('z-40');
// sirsoft-admin_basic 의 .sticky-tab-nav-responsive 자산 사용 (responsive padding 화면 전용 — #408)
// 자산 정의: sticky top-0 z-40 -mx-{4,6,8} px-{4,6,8} border-b bg-gray-50 dark:bg-gray-900
expect(stickyHeader.props.className).toContain('sticky-tab-nav-responsive');
// 하위 탭 네비게이션이 basic_defaults 탭에서만 표시 (TabNavigationScroll - 스크롤 방식)
const subTabNav = findById(content, 'sub_tab_navigation');
@@ -318,20 +343,21 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
expect(subTabIds).toContain('bulk_apply');
});
it('저장 버튼이 sticky 헤더 안에 있다', () => {
it('저장 버튼이 하단 sticky footer 안에 있다', () => {
const content = mainLayout.slots.content[0];
const stickyHeader = findById(content, 'sticky_header');
const saveBtn = findById(stickyHeader, 'save_button');
expect(saveBtn).toBeDefined();
// 하단 footer_buttons가 없어야 함
// 액션 버튼은 페이지 상단 sticky_header 가 아닌 하단 sticky footer_buttons 컨테이너에 있다 (#369)
// sirsoft-admin_basic 의 .sticky-footer-buttons 자산 사용 (sticky bottom-0 z-10 ... bg-gray-50 dark:bg-gray-800)
const footerButtons = findById(content, 'footer_buttons');
expect(footerButtons).toBeNull();
expect(footerButtons).toBeDefined();
expect(footerButtons.props.className).toContain('sticky-footer-buttons');
const saveBtn = findById(footerButtons, 'footer_save_button');
expect(saveBtn).toBeDefined();
});
it('저장 버튼이 올바른 apiCall 구조를 가진다', () => {
const content = mainLayout.slots.content[0];
const saveBtn = findById(content, 'save_button');
const saveBtn = findById(content, 'footer_save_button');
expect(saveBtn).toBeDefined();
expect(saveBtn.props.type).toBe('button');
expect(saveBtn.props.disabled).toContain('hasChanges');
@@ -382,9 +408,10 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
it('Partial 참조가 올바르다 (8개 하위 탭 partial 포함)', () => {
const content = mainLayout.slots.content[0];
const mainContent = findById(content, 'main_content');
expect(mainContent).toBeDefined();
const partials = mainContent.children.filter((c: any) => c.partial);
// _admin_base 슬롯 #main_content 와 중복 회피 — 페이지 안쪽 탭 컨텐츠 컨테이너 id 는 tab_content (#408)
const tabContent = findById(content, 'tab_content');
expect(tabContent).toBeDefined();
const partials = tabContent.children.filter((c: any) => c.partial);
expect(partials.length).toBeGreaterThanOrEqual(10);
const partialPaths = partials.map((p: any) => p.partial);
@@ -432,11 +459,10 @@ describe('_tab_board_settings_basic.json - 기본 하위 탭', () => {
expect(tabBoardSettingsBasic.id).toBe('basic');
});
it('페이지 안내 배너(page_info_banner)가 섹션 목록 상단에 존재한다', () => {
it('페이지 안내 배너(page_info_banner)가 섹션 목록 상단에 존재한다 (#408 alert-info)', () => {
const banner = findById(tabBoardSettingsBasic, 'page_info_banner');
expect(banner).toBeDefined();
expect(banner.props.className).toContain('bg-blue-50');
expect(banner.props.className).toContain('dark:bg-blue-900');
expect(banner.props.className).toContain('alert-info');
const texts = findByName(banner, 'P');
expect(texts.some((p: any) => p.text?.includes('page_info'))).toBe(true);
});
@@ -444,9 +470,10 @@ describe('_tab_board_settings_basic.json - 기본 하위 탭', () => {
it('SectionLayout을 사용한다', () => {
const sections = findByName(tabBoardSettingsBasic, 'SectionLayout');
expect(sections.length).toBeGreaterThanOrEqual(1);
// sirsoft-admin_basic 의 .admin-card 자산 사용 (#408)
// 자산 정의: bg-white border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700
for (const section of sections) {
expect(section.props.className).toContain('bg-white');
expect(section.props.className).toContain('dark:bg-gray-800');
expect(section.props.className).toContain('admin-card');
}
});
@@ -615,9 +642,9 @@ describe('_tab_board_settings_permissions.json - 권한 하위 탭', () => {
const section = findById(tabBoardSettingsPermissions, sectionId);
expect(section, `${sectionId} 노드 존재`).toBeDefined();
// 섹션 헤더는 첫 자식 Label (cursor-pointer + 체크박스 감쌈)
// 섹션 헤더는 첫 자식 Label (.section-header-row 시맨틱 자산 적용 + 체크박스 감쌈, #408)
const headerLabel = section.children.find(
(c: any) => c.name === 'Label' && (c.props?.className ?? '').includes('cursor-pointer')
(c: any) => c.name === 'Label' && (c.props?.className ?? '').includes('section-header-row')
);
expect(headerLabel, `${sectionId} 섹션 헤더 Label 존재`).toBeDefined();
@@ -765,6 +792,23 @@ describe('_tab_board_settings_attachment.json - 첨부파일 하위 탭', () =>
expect(allowedExt).toBeDefined();
expect(allowedExt.if).toBeUndefined();
});
it('허용 확장자 안내 문구가 descriptions i18n 키를 참조한다 (회귀)', () => {
// 레이아웃은 안내 문구를 descriptions.allowed_extensions 키로 바인딩해야 한다.
const descNode = findFirst(
tabBoardSettingsAttachment,
(n) => n?.text === '$t:sirsoft-board.admin.settings.fields.descriptions.allowed_extensions'
);
expect(descNode).not.toBeNull();
});
it('허용 확장자 안내 ko 문구가 "최소 1개" 의미로 갱신되었다 (회귀)', () => {
// 빈 값 저장 차단으로 정책이 바뀌었으므로 안내 문구도 "최소 1개 입력"이어야 하며,
// 정반대 의미의 옛 문구("빈 값 ... 모든 확장자")가 남아 있으면 안 된다.
const desc = (koSettingsLang as any).fields.descriptions.allowed_extensions;
expect(desc).toContain('최소 1개');
expect(desc).not.toContain('모든 확장자');
});
});
// =============================================
@@ -814,6 +858,23 @@ describe('_tab_board_settings_bulk_apply.json - 일괄 적용 하위 탭', () =>
expect(openModalActions).toHaveLength(1);
expect(openModalActions[0].target).toBe('bulk_apply_confirm_modal');
});
// 회귀(#413-26): 롤백 발생 후 모달을 닫았다가 다시 열면 직전 롤백 안내가
// 잔존하던 결함 — 모달 열기 sequence 에서 bulkApplyAborted/Board 도 함께 초기화해야 함.
it('모달 열기 시 직전 롤백 안내 상태를 초기화한다 (saveError + aborted + abortedBoard)', () => {
const bulkApply = findById(tabBoardSettingsBulkApply, 'section_bulk_apply');
// openModal 직전의 setState(local) 중 오류/롤백 안내를 초기화하는 액션을 찾는다
const resetActions = findActions(bulkApply, 'setState').filter(
(a: any) => a.params?.target === 'local'
&& Object.prototype.hasOwnProperty.call(a.params, 'bulkApplySaveError')
);
expect(resetActions.length).toBeGreaterThanOrEqual(1);
const reset = resetActions[0];
expect(reset.params.bulkApplySaveError).toBe(false);
// 롤백 안내도 함께 초기화되어야 재오픈 시 직전 실패 문구가 잔존하지 않는다
expect(reset.params.bulkApplyAborted).toBe(false);
expect(reset.params.bulkApplyAbortedBoard).toBeNull();
});
});
// =============================================
@@ -829,8 +890,8 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
it('SectionLayout을 사용한다', () => {
const sections = findByName(tabReportPolicy, 'SectionLayout');
expect(sections.length).toBeGreaterThanOrEqual(1);
expect(sections[0].props.className).toContain('bg-white');
expect(sections[0].props.className).toContain('dark:bg-gray-800');
// sirsoft-admin_basic 의 .admin-card 자산 사용 (#408)
expect(sections[0].props.className).toContain('admin-card');
});
it('신고 정책 필드가 모두 존재한다', () => {
@@ -844,16 +905,179 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
expect(fields).not.toContain('report_policy.suspension_days');
});
it('알림 설정 필드가 존재한다 (이슈 #35 Phase 1+2)', () => {
it('신고 알림 활성화 토글이 제거되었다 (알림 정의 활성화는 알림 설정 메뉴에서 관리)', () => {
// 활성화 토글은 백엔드에서 항상 true 강제 저장되므로 화면에서 제거됨.
const fields = findFormFields(tabReportPolicy);
expect(fields).not.toContain('report_policy.notify_admin_on_report');
expect(fields).not.toContain('report_policy.notify_author_on_report_action');
// Phase 2: 신고 접수 관리자 알림 토글 + 이메일 채널 TagInput
expect(fields).toContain('report_policy.notify_admin_on_report');
expect(fields).toContain('report_policy.notify_admin_on_report_channels');
// 두 알림 카드(헤더)는 유지된다 (제목/설명 표시)
expect(findById(tabReportPolicy, 'field_notify_admin_on_report')).toBeDefined();
expect(findById(tabReportPolicy, 'field_notify_author_on_report_action')).toBeDefined();
});
// Phase 1: 신고 처리 알림 토글 + 이메일 채널 TagInput
expect(fields).toContain('report_policy.notify_author_on_report_action');
expect(fields).toContain('report_policy.notify_author_on_report_action_channels');
it('토글 제거로 발송 범위 라디오가 항상 표시된다 (if 가드 제거)', () => {
// notify_admin_on_report === true 가드가 없어도 scope RadioGroup 이 렌더되어야 한다.
const scopeField = findById(tabReportPolicy, 'field_notify_admin_on_report_scope');
expect(scopeField).toBeDefined();
const radioGroups = findByName(scopeField, 'RadioGroup');
expect(radioGroups).toHaveLength(1);
// 가드 Div 의 if 표현식에 토글 종속 조건이 없어야 한다
const adminField = findById(tabReportPolicy, 'field_notify_admin_on_report');
const guardWithToggle = findFirst(
adminField,
(n: any) => typeof n.if === 'string' && n.if.includes('notify_admin_on_report === true'),
);
expect(guardWithToggle).toBeNull();
});
it('채널 직접 선택 TagInput 대신 템플릿별 활성 상태 표시로 대체되었다', () => {
// 코어 알림 인프라 도입 후, 채널은 알림 설정에서 관리.
// 신고정책 탭은 대응 definition 의 채널별 템플릿 활성 상태만 읽기전용 표시한다.
const fields = findFormFields(tabReportPolicy);
expect(fields).not.toContain('report_policy.notify_admin_on_report_channels');
expect(fields).not.toContain('report_policy.notify_author_on_report_action_channels');
// 신고 접수: report_received_admin definition 템플릿 상태 블록 + 이동 버튼
const adminStatus = findById(tabReportPolicy, 'report_received_admin_template_status');
expect(adminStatus).toBeDefined();
const adminIteration = findFirst(
adminStatus,
(n: any) => n.iteration && String(n.iteration.source).includes("'report_received_admin'"),
);
expect(adminIteration).toBeTruthy();
expect(adminIteration.iteration.item_var).toBe('tpl');
// 각 행이 "어떤 템플릿인지" 알 수 있도록 채널명 + 템플릿 제목(subject)을 표시
const adminSubject = findFirst(adminIteration, (n: any) => n.name === 'Span' && String(n.text ?? '').includes('tpl.subject'));
expect(adminSubject).toBeTruthy();
expect(findById(tabReportPolicy, 'go_to_notification_settings_admin')).toBeDefined();
// 신고 처리: report_action definition 템플릿 상태 블록 + 이동 버튼
const authorStatus = findById(tabReportPolicy, 'report_action_template_status');
expect(authorStatus).toBeDefined();
const authorIteration = findFirst(
authorStatus,
(n: any) => n.iteration && String(n.iteration.source).includes("'report_action'"),
);
expect(authorIteration).toBeTruthy();
const authorSubject = findFirst(authorIteration, (n: any) => n.name === 'Span' && String(n.text ?? '').includes('tpl.subject'));
expect(authorSubject).toBeTruthy();
expect(findById(tabReportPolicy, 'go_to_notification_settings_author')).toBeDefined();
});
it('admin-card 안 4 섹션 description 이 .card-description 시맨틱이고 card-title 의 직계 sibling 이다 (#408)', () => {
// _tab_report_policy.json 의 4 섹션 (자동 숨김, abuse_prevention, permissions, notification)
// description 이 stack 안에 묻혀있으면 .card-title:has(+ .card-description) 셀렉터 불발 →
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (PO 보고 #408)
//
// 기대: admin-card 직계 자식 순서가 [card-title, card-description, stack, ...] 이고,
// card-description 의 className 에 'card-description' 시맨틱 자산이 포함되어야 함.
function collectAdminCards(node: any, out: any[] = []): any[] {
if (!node || typeof node !== 'object') return out;
if (Array.isArray(node)) { node.forEach((c) => collectAdminCards(c, out)); return out; }
const cls = node.props?.className;
if (typeof cls === 'string' && /\badmin-card\b/.test(cls)) out.push(node);
for (const v of Object.values(node)) if (v && typeof v === 'object') collectAdminCards(v, out);
return out;
}
const cards = collectAdminCards(tabReportPolicy);
expect(cards.length, '_tab_report_policy admin-card 개수').toBeGreaterThan(0);
for (const card of cards) {
const children = card.children ?? [];
// card-title 찾기 (H1~H4/Span/P 중 className 에 card-title 포함)
const titleIdx = children.findIndex(
(c: any) => typeof c?.props?.className === 'string' &&
/\bcard-title\b/.test(c.props.className)
);
if (titleIdx === -1) continue; // card-title 없는 admin-card 는 검사 대상 아님
// card-title 의 다음 형제가 stack 인 경우 — stack 첫 자식이 description-like 패턴이면 위반
const sibling = children[titleIdx + 1];
if (!sibling || sibling.name !== 'Div') continue;
const sibCls = typeof sibling.props?.className === 'string' ? sibling.props.className : '';
const isStack = /\b(stack|stack-tight|stack-flush)\b/.test(sibCls);
if (!isStack) continue;
const stackChildren = sibling.children ?? [];
// stack 첫 자식 (직접 OR inert wrapper Div 안)
let first = stackChildren[0];
const isInert = (n: any) =>
n && n.type === 'basic' && n.name === 'Div' && !n.id && !n.if && !n.iteration && !n.actions && !n.lifecycle &&
(!n.props || !n.props.className || n.props.className.trim() === '');
if (isInert(first) && Array.isArray(first.children) && first.children.length === 1) {
first = first.children[0];
}
if (!first || (first.name !== 'Span' && first.name !== 'P')) continue;
const fCls = typeof first.props?.className === 'string' ? first.props.className : '';
const isDescLike =
/\btext-label-subtle\b/.test(fCls) ||
/\bform-hint\b/.test(fCls) ||
(/\btext-tertiary\b/.test(fCls) && /\bblock\b/.test(fCls));
if (isDescLike) {
// 위반 — stack 첫 자식 description 이 hoist 되지 않음
throw new Error(
`admin-card 안 description 이 stack 에 묻혀있음. card-title 의 직계 sibling 으로 hoist + .card-description 시맨틱 적용 필요. (className="${fCls}")`,
);
}
}
});
it('admin_board_settings 메인 레이아웃에 id="main_content" 가 없어야 한다 — _admin_base 의 슬롯 id 와 중복 금지 (HTML 스펙) (#408)', () => {
function findAllIds(node: any, out: Map<string, number> = new Map()): Map<string, number> {
if (!node || typeof node !== 'object') return out;
if (Array.isArray(node)) { node.forEach((c) => findAllIds(c, out)); return out; }
if (typeof node.id === 'string' && node.id !== '') {
out.set(node.id, (out.get(node.id) ?? 0) + 1);
}
for (const v of Object.values(node)) if (v && typeof v === 'object') findAllIds(v, out);
return out;
}
const ids = findAllIds(mainLayout);
// 메인 레이아웃 (admin_board_settings.json) 자체에는 main_content id 가 있으면 안 됨.
// _admin_base.json 슬롯 컨테이너가 이미 #main_content 를 가지고 있어 페이지에 중복 발생.
expect(ids.get('main_content'), 'admin_board_settings 안쪽에 id="main_content" 가 있으면 _admin_base 슬롯과 중복').toBeUndefined();
// 대체 id (탭 컨텐츠 컨테이너) 가 존재해야 함
expect(ids.has('tab_content'), '탭 컨텐츠 컨테이너 id="tab_content" 가 존재해야 함').toBe(true);
});
it('board layout 의 모든 .card-description 은 Div 태그여야 한다 — Span/P 금지 (#408)', () => {
// PO 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
// → div.card-description 만 허용.
const layouts: Array<[string, any]> = [
['mainLayout', mainLayout],
['tabBoardSettingsBasic', tabBoardSettingsBasic],
['tabBoardSettingsPermissions', tabBoardSettingsPermissions],
['tabBoardSettingsList', tabBoardSettingsList],
['tabBoardSettingsPost', tabBoardSettingsPost],
['tabBoardSettingsReply', tabBoardSettingsReply],
['tabBoardSettingsComment', tabBoardSettingsComment],
['tabBoardSettingsAttachment', tabBoardSettingsAttachment],
['tabBoardSettingsNotification', tabBoardSettingsNotification],
['tabBoardSettingsBulkApply', tabBoardSettingsBulkApply],
['tabReportPolicy', tabReportPolicy],
['tabSpamSecurity', tabSpamSecurity],
['bulkApplyModal', bulkApplyModal],
['tabGeneral', tabGeneral],
['tabSeo', tabSeo],
];
const violations: string[] = [];
function check(node: any, layoutName: string): void {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) { node.forEach((c) => check(c, layoutName)); return; }
const cls = node.props?.className;
if (typeof cls === 'string' && /\bcard-description\b/.test(cls) && node.name !== 'Div') {
violations.push(`${layoutName}: ${node.name}.card-description (text="${(node.text ?? '').slice(0, 60)}")`);
}
for (const v of Object.values(node)) if (v && typeof v === 'object') check(v, layoutName);
}
layouts.forEach(([name, l]) => check(l, name));
expect(violations, '비-Div 태그를 사용하는 .card-description 가 있으면 안 됨').toEqual([]);
});
it('Phase 3: notify_admin_on_report_scope RadioGroup가 존재한다', () => {
@@ -923,16 +1147,60 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
}
});
it('Input에 게시판 폼 스타일 클래스가 적용되어 있다', () => {
it('Input 에러 시 input-error 시맨틱 클래스가 분기 적용된다', () => {
// Input 디폴트 외형은 Input 컴포넌트가 자체 제공 (시맨틱 .input).
// 호출처 className 표현식은 422 검증 에러 시 input-error 시맨틱을 토글하는 분기만 담당 (#369).
const inputs = findByName(tabReportPolicy, 'Input').filter(
(c: any) => c.props?.type === 'number'
);
expect(inputs.length).toBeGreaterThan(0);
for (const input of inputs) {
expect(input.props.className).toContain('border');
expect(input.props.className).toContain('rounded-md');
expect(input.props.className).toContain('dark:bg-gray-700');
expect(input.props.className).toMatch(/_local\.errors/);
expect(input.props.className).toContain('input-error');
}
});
it('신고 관리 권한 TagInput 에러 시 input-error 분기 + 인라인 에러 Span 이 존재한다 (#413 item 8)', () => {
// 신고 권한 필드(view_roles/manage_roles)는 빈 배열 제출 시 FormRequest 가 차단한다.
// 그 422 응답을 화면에 표시하기 위해, 다른 number Input 필드와 동일한 패턴으로
// 1) TagInput control 에 input-error 시맨틱 토글 (빨간 테두리)
// 2) TagInput 직후 형제로 인라인 에러 메시지 Span
// 두 가지가 모두 있어야 한다. (#408 시맨틱화 유지 — 원시 Tailwind 풀스택 금지)
const permissionFields = ['report_permissions.view_roles', 'report_permissions.manage_roles'];
// TagInput 과 그 바로 뒤 형제 Span 을 함께 보기 위해, 부모의 children 배열을 순회한다.
function collectSiblingPairs(node: any, out: Array<{ tag: any; next: any }> = []): Array<{ tag: any; next: any }> {
if (!node || typeof node !== 'object') return out;
const children = Array.isArray(node.children) ? node.children : [];
for (let i = 0; i < children.length; i++) {
const c = children[i];
if (c?.name === 'TagInput' && permissionFields.includes(c.props?.name)) {
out.push({ tag: c, next: children[i + 1] ?? null });
}
collectSiblingPairs(c, out);
}
return out;
}
const pairs = collectSiblingPairs(tabReportPolicy);
// view_roles + manage_roles 두 필드 모두 검출되어야 함
expect(pairs.map((p) => p.tag.props.name).sort()).toEqual([...permissionFields].sort());
for (const { tag, next } of pairs) {
const field = tag.props.name;
// 1) className 이 에러 시 input-error 를 토글하는 분기여야 함
expect(tag.props.className, `${field} className`).toMatch(/_local\.errors/);
expect(tag.props.className, `${field} className`).toContain('input-error');
// 2) TagInput 직후 형제가 인라인 에러 Span 이어야 함 (if + 빨간 텍스트 + 해당 필드 바인딩)
expect(next, `${field} 뒤 에러 Span`).not.toBeNull();
expect(next.name).toBe('Span');
expect(next.if).toContain(`_local.errors?.['${field}']`);
expect(next.props.className).toContain('text-red-600');
expect(next.text).toContain(`_local.errors`);
expect(next.text).toContain(field);
}
});
});
@@ -1057,23 +1325,54 @@ describe('_bulk_apply_modal.json - 일괄 적용 확인 모달', () => {
// body는 _global.bulkApplySnapshot을 사용
expect(bulkApiCall.params.body).toContain('bulkApplySnapshot');
// bulk-apply 성공: setState(isBulkApplying=false, fields 초기화) → setState(snapshot=null) → closeModal → toast
expect(bulkApiCall.onSuccess).toHaveLength(4);
// bulk-apply onSuccess: 공통(진행해제) + 성공분기(if !rolled_back) 4개 + 롤백분기(if rolled_back) 2개 = 7개
expect(bulkApiCall.onSuccess).toHaveLength(7);
// [0] 성공/롤백 공통: isBulkApplying=false (조건 없음)
expect(bulkApiCall.onSuccess[0].handler).toBe('setState');
expect(bulkApiCall.onSuccess[0].params.isBulkApplying).toBe(false);
expect(bulkApiCall.onSuccess[1].handler).toBe('setState');
expect(bulkApiCall.onSuccess[1].params.target).toBe('global');
expect(bulkApiCall.onSuccess[1].params.bulkApplySnapshot).toBeNull();
expect(bulkApiCall.onSuccess[2].handler).toBe('closeModal');
expect(bulkApiCall.onSuccess[3].handler).toBe('toast');
expect(bulkApiCall.onSuccess[0].if).toBeUndefined();
// bulk-apply 실패: setState(isBulkApplying=false) → setState(snapshot=null) → toast
// 성공 분기(rolled_back=false): 상태초기화 → snapshot=null → closeModal → success 토스트
const successActions = bulkApiCall.onSuccess.filter(
(a: any) => a.if === '{{!response?.data?.rolled_back}}'
);
expect(successActions).toHaveLength(4);
expect(successActions.some((a: any) => a.handler === 'closeModal')).toBe(true);
const successToast = successActions.find((a: any) => a.handler === 'toast');
expect(successToast).toBeDefined();
expect(successToast.params.type).toBe('success');
// 성공 시 global snapshot 정리
const successSnapshotReset = successActions.find(
(a: any) => a.handler === 'setState' && a.params.target === 'global'
);
expect(successSnapshotReset.params.bulkApplySnapshot).toBeNull();
// 롤백 분기(rolled_back=true): 안내상태 setState + error 토스트 (closeModal 없음 → 모달 유지)
const rollbackActions = bulkApiCall.onSuccess.filter(
(a: any) => a.if === '{{response?.data?.rolled_back}}'
);
expect(rollbackActions).toHaveLength(2);
// 롤백 분기에는 closeModal이 없어야 모달이 닫히지 않고 안내 박스가 노출된다
expect(rollbackActions.some((a: any) => a.handler === 'closeModal')).toBe(false);
const rollbackSetState = rollbackActions.find((a: any) => a.handler === 'setState');
expect(rollbackSetState.params.target).toBe('$parent._local');
expect(rollbackSetState.params.bulkApplyAborted).toBe(true);
// 실패 게시판명을 응답에서 받아 안내 문구 분기에 사용
expect(rollbackSetState.params.bulkApplyAbortedBoard).toBe(
'{{response?.data?.board?.name ?? null}}'
);
const rollbackToast = rollbackActions.find((a: any) => a.handler === 'toast');
expect(rollbackToast.params.type).toBe('error');
// bulk-apply onError(네트워크/예외): setState(isBulkApplying=false) → setState(snapshot=null) → toast(error)
expect(bulkApiCall.onError).toHaveLength(3);
expect(bulkApiCall.onError[0].handler).toBe('setState');
expect(bulkApiCall.onError[0].params.isBulkApplying).toBe(false);
expect(bulkApiCall.onError[1].handler).toBe('setState');
expect(bulkApiCall.onError[1].params.bulkApplySnapshot).toBeNull();
expect(bulkApiCall.onError[2].handler).toBe('toast');
expect(bulkApiCall.onError[2].params.type).toBe('error');
// 저장 실패: setState(isBulkApplying=false, bulkApplySaveError=true) → setState(snapshot=null)
expect(saveApiCall.onError).toHaveLength(2);
@@ -1107,6 +1406,57 @@ describe('_bulk_apply_modal.json - 일괄 적용 확인 모달', () => {
expect(json).toContain('bulkApplySaveError');
expect(json).toContain('bulk_apply_modal.save_error');
});
it('일괄 적용 중단(전체 롤백) 안내 박스가 조건부로 표시된다 (오류=red 톤)', () => {
// bulkApplyAborted가 true일 때만 노출되는 오류(danger) 안내 박스
const abortedBox = findByName(bulkApplyModal, 'Div').find(
(d: any) => d.if === '{{$parent._local.bulkApplyAborted}}'
);
expect(abortedBox).toBeDefined();
// 롤백은 오류이므로 amber(warning)가 아닌 red(danger) 톤
expect(abortedBox.props.className).toContain('alert-danger');
// 오류 아이콘도 red 계열
const icon = (abortedBox.children ?? []).find((c: any) => c.name === 'Icon');
expect(icon.props.className).toContain('text-red-600');
});
it('롤백 안내 문구가 게시판명 유무로 분기된다 (권한 실패=게시판명 강조 / generic)', () => {
const abortedBox = findByName(bulkApplyModal, 'Div').find(
(d: any) => d.if === '{{$parent._local.bulkApplyAborted}}'
);
const spans = (abortedBox.children ?? []).filter((c: any) => c.name === 'Span');
// 권한 실패(게시판명 있음): 게시판명을 굵게(font-bold) 강조한 Span + suffix 문구 Span
const boardSpan = spans.find(
(s: any) => s.if === '{{$parent._local.bulkApplyAbortedBoard}}'
);
expect(boardSpan).toBeDefined();
const innerSpans = (boardSpan.children ?? []).filter((c: any) => c.name === 'Span');
// 첫 Span: 게시판명, 굵게 강조
const nameSpan = innerSpans.find((s: any) => s.props?.className?.includes('font-bold'));
expect(nameSpan).toBeDefined();
expect(nameSpan.text).toContain('$parent._local.bulkApplyAbortedBoard');
// 둘째 Span: suffix 문구
const suffixSpan = innerSpans.find((s: any) =>
typeof s.text === 'string' && s.text.includes('aborted_message_suffix')
);
expect(suffixSpan).toBeDefined();
// 기타 실패(게시판명 없음): generic 문구
const genericSpan = spans.find(
(s: any) => s.if === '{{!$parent._local.bulkApplyAbortedBoard}}'
);
expect(genericSpan).toBeDefined();
expect(genericSpan.text).toContain('bulk_apply_modal.aborted_message_generic');
});
it('확인 버튼 진입 시 직전 롤백 안내 상태를 초기화한다 (재시도 대비)', () => {
const confirmBtn = findById(bulkApplyModal, 'bulk_apply_confirm_button');
const firstSetState = confirmBtn.actions[0].actions[0];
expect(firstSetState.handler).toBe('setState');
expect(firstSetState.params.bulkApplyAborted).toBe(false);
expect(firstSetState.params.bulkApplyAbortedBoard).toBeNull();
});
});
// =============================================
@@ -1199,7 +1549,7 @@ describe('크로스 파일 검증', () => {
it('저장 body에 notification channels 포함 로직이 있다', () => {
const content = mainLayout.slots.content[0];
const saveBtn = findById(content, 'save_button');
const saveBtn = findById(content, 'footer_save_button');
const apiCallAction = saveBtn.actions[0].params.actions[1];
// body 표현식 안에서 notifications.channels 구조를 조립한다
@@ -1309,7 +1659,7 @@ describe('기본 설정 탭 (general) - 날짜 표시 방식', () => {
it('저장 body에 general 탭 분기 로직이 있다', () => {
const content = mainLayout.slots.content[0];
const saveBtn = findById(content, 'save_button');
const saveBtn = findById(content, 'footer_save_button');
const apiCallAction = saveBtn.actions[0].params.actions[1];
const body = apiCallAction.params.body as string;
@@ -1336,22 +1686,23 @@ describe('_tab_seo.json - SEO 설정 탭', () => {
expect(tabSeo.if).toContain("'general'");
});
it('메타 설정 카드가 존재한다', () => {
const metaCard = findById(tabSeo, 'meta_settings_card');
expect(metaCard).toBeDefined();
expect(metaCard.name).toBe('Div');
it('메타 설정 섹션 헤더가 존재한다 (section-header)', () => {
const header = findById(tabSeo, 'meta_settings_header');
expect(header).not.toBeNull();
expect(header.props.className).toBe('section-header');
});
it('아코디언 3개가 올바르게 정의되어 있다', () => {
const accordions = [
'accordion_boards_page',
'accordion_board_page',
'accordion_post_page',
it('페이지별 admin-card 3개가 정의되어 있다 (boards/board/post)', () => {
const cards = [
'boards_page_card',
'board_page_card',
'post_page_card',
];
for (const id of accordions) {
for (const id of cards) {
const node = findById(tabSeo, id);
expect(node, `${id} 아코디언이 없다`).toBeDefined();
expect(node.props.className).toContain('card-accordion');
expect(node, `${id} 카드가 없다`).not.toBeNull();
expect(node.props.className).toBe('admin-card');
expect(node.name).toBe('SectionLayout');
}
});
@@ -1479,8 +1830,9 @@ describe('_tab_seo.json - SEO 설정 탭', () => {
it('메인 레이아웃 Partial 목록에 _tab_seo.json이 포함되어 있다', () => {
const content = mainLayout.slots.content[0];
const mainContent = findById(content, 'main_content');
const partialPaths = mainContent.children
// _admin_base 슬롯 #main_content 와 중복 회피 — tab_content 로 변경 (#408)
const tabContent = findById(content, 'tab_content');
const partialPaths = tabContent.children
.filter((c: any) => c.partial)
.map((p: any) => p.partial as string);
expect(partialPaths.some((p) => p.includes('_tab_seo.json'))).toBe(true);
@@ -1488,7 +1840,7 @@ describe('_tab_seo.json - SEO 설정 탭', () => {
it('저장 body에 seo 탭 분기 로직이 있다', () => {
const content = mainLayout.slots.content[0];
const saveBtn = findById(content, 'save_button');
const saveBtn = findById(content, 'footer_save_button');
const apiCallAction = saveBtn.actions[0].params.actions[1];
const body = apiCallAction.params.body as string;
expect(body).toContain("'seo'");
@@ -0,0 +1,78 @@
/**
* 관리자 게시글 상세 - cascade 댓글 노출 분기 회귀 테스트 (#413-69-②)
*
* @description
* 게시글 삭제로 함께 숨겨진(cascade) 댓글이 사용자 직접 삭제 댓글과 동일하게
* "삭제된 댓글입니다" 로 마스킹되던 회귀의 방지.
*
* 정책(PO 확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
* 백엔드(CommentResource)가 is_cascade_deleted 플래그를 내려주고,
* 레이아웃은 그 플래그로 분기를 결정한다.
*
* 분기 계약:
* - deleted_comment_content (마스킹): deleted_at && !is_cascade_deleted
* → user 선삭제분만 마스킹, cascade 는 제외
* - normal_comment_content (원문): status!=='blinded' && (!deleted_at || is_cascade_deleted)
* → cascade 댓글은 일반 댓글처럼 원문 노출
*/
import { describe, it, expect } from 'vitest';
import commentPartial from '../../../layouts/admin/partials/admin_board_post_detail/_comment.json';
/**
* JSON 트리에서 주어진 id 의 노드를 찾습니다.
*
* @param node 탐색 시작 노드
* @param id 찾을 노드 id
* @returns 일치하는 노드 또는 null
*/
function findById(node: any, id: string): any {
if (!node || typeof node !== 'object') return null;
if (node.id === id) return node;
if (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;
}
describe('관리자 댓글 - cascade 노출 분기 (#413-69-②)', () => {
const deletedNode = findById(commentPartial, 'deleted_comment_content');
const normalNode = findById(commentPartial, 'normal_comment_content');
it('마스킹 분기(deleted_comment_content)는 cascade 댓글을 제외한다', () => {
expect(deletedNode, '삭제 댓글 마스킹 노드가 존재해야 한다').toBeTruthy();
expect(deletedNode.if).toContain('comment?.deleted_at');
expect(
deletedNode.if,
'cascade 댓글은 마스킹 분기에서 제외되어야 한다 (!is_cascade_deleted)'
).toContain('!comment?.is_cascade_deleted');
});
it('일반 분기(normal_comment_content)는 cascade 댓글의 원문을 노출한다', () => {
expect(normalNode, '일반 댓글 노드가 존재해야 한다').toBeTruthy();
expect(normalNode.if).toContain("comment?.status !== 'blinded'");
expect(
normalNode.if,
'cascade 댓글은 deleted_at 이 있어도 일반 분기로 노출되어야 한다'
).toContain('comment?.is_cascade_deleted');
});
it('일반 분기는 cascade 댓글 본문을 comment.content 로 직접 렌더한다 (마스킹 없음)', () => {
expect(normalNode.text).toBe('{{comment.content}}');
});
});
@@ -0,0 +1,65 @@
/**
* 관리자 답변하기 화면 - 원글 작성자 표시 키 경로 회귀 테스트 (#413-70-1)
*
* @description
* 답변하기 폼 상단의 원글 정보 헤더에서 작성자 이름이 '알 수 없음'으로
* 표시되던 버그의 회귀 방지.
*
* 근본 원인: 레이아웃이 `parent_post.user.name`(존재하지 않는 키)을 참조.
* 백엔드(getFormMeta → PostResource::toArray)는 작성자를 `author` 키로 노출하므로
* `parent_post.author.name`을 참조해야 한다.
*/
import { describe, it, expect } from 'vitest';
import parentPost from '../../../layouts/admin/partials/admin_board_post_form/_parent_post.json';
/**
* JSON 트리에서 text 표현식을 가진 모든 노드를 재귀 수집합니다.
*
* @param node 탐색 시작 노드
* @returns text 문자열 배열
*/
function collectTexts(node: any): string[] {
const results: string[] = [];
if (!node || typeof node !== 'object') return results;
if (typeof node.text === 'string') {
results.push(node.text);
}
if (Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...collectTexts(child));
}
}
if (node.slots) {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
results.push(...collectTexts(child as any));
}
}
}
}
return results;
}
describe('관리자 답변하기 - 원글 작성자 키 경로 (#413-70-1)', () => {
const texts = collectTexts(parentPost);
it('원글 작성자 이름을 parent_post.author.name 경로로 참조한다', () => {
const authorText = texts.find((t) => t.includes('parent_post') && t.includes('.name'));
expect(authorText, '원글 작성자 이름 표현식이 존재해야 한다').toBeDefined();
expect(authorText).toContain('parent_post?.author?.name');
});
it('존재하지 않는 parent_post.user.name 경로를 참조하지 않는다 (알 수 없음 회귀 방지)', () => {
const usesWrongPath = texts.some((t) => t.includes('parent_post?.user?.name'));
expect(usesWrongPath, 'parent_post.user.name 은 백엔드에 없는 키 — author 로 참조해야 함').toBe(false);
});
});
@@ -0,0 +1,322 @@
/**
* 관리자 대시보드 모듈 layout extension JSON 구조 검증 테스트
*
* @description
* - quick_menu extension: 게시판/신고 버튼 2개 + 슬롯/모드/priority
* - community extension: data_sources 4개 + ExtensionBadge + 카드 4종(오늘 배지/추세 그래프/최신글/미처리 신고)
* - 데이터 바인딩 경로 (board_overview / board_post_graph / board_recent_posts / board_pending_reports)
* - 다국어 키 (sirsoft-board.admin.dashboard.*)
* - iteration 변수명 (item_var/index_var)
* - "전체 보기" 링크 (/admin/boards, /admin/boards/reports)
*/
import { describe, it, expect } from 'vitest';
import quickMenuExt from '../../../extensions/admin_dashboard_quick_menu.json';
import communityExt from '../../../extensions/admin_dashboard_community.json';
/**
* 컴포넌트 트리에서 특정 ID 를 가진 노드를 재귀적으로 찾습니다.
* extension JSON 의 components/children 양쪽을 모두 탐색합니다.
*/
function findById(node: any, id: string): any | null {
if (!node) return null;
if (Array.isArray(node)) {
for (const item of node) {
const found = findById(item, id);
if (found) return found;
}
return null;
}
if (typeof node !== 'object') return null;
if (node.id === id) return node;
for (const key of ['components', 'children']) {
if (node[key] && Array.isArray(node[key])) {
const found = findById(node[key], id);
if (found) return found;
}
}
return null;
}
/**
* 컴포넌트 트리를 순회하여 특정 name 을 가진 모든 컴포넌트를 찾습니다.
*/
function findByName(node: any, name: string): any[] {
const results: any[] = [];
if (!node) return results;
if (Array.isArray(node)) {
for (const item of node) results.push(...findByName(item, name));
return results;
}
if (typeof node !== 'object') return results;
if (node.name === name) results.push(node);
for (const key of ['components', 'children']) {
if (node[key] && Array.isArray(node[key])) {
results.push(...findByName(node[key], name));
}
}
return results;
}
/**
* 텍스트 트리에서 모든 $t: 키를 추출합니다.
*/
function extractTKeys(node: any): string[] {
const keys = new Set<string>();
const walk = (n: any) => {
if (!n) return;
if (typeof n === 'string') {
const m = n.match(/\$t:([a-zA-Z0-9._-]+)/g);
if (m) m.forEach(k => keys.add(k.slice(3)));
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const k of Object.keys(n)) walk(n[k]);
}
};
walk(node);
return [...keys].sort();
}
describe('admin_dashboard_quick_menu.json - quick_menu 슬롯 주입', () => {
it('extension_point / mode / priority 가 올바르다', () => {
expect(quickMenuExt.extension_point).toBe('admin_dashboard_quick_menu');
expect(quickMenuExt.mode).toBe('append');
expect(quickMenuExt.priority).toBe(50);
});
it('게시판/신고 버튼 2개를 주입한다', () => {
expect(quickMenuExt.components).toHaveLength(2);
const ids = quickMenuExt.components.map((c: any) => c.id);
expect(ids).toEqual(['qm_boards', 'qm_reports']);
});
it('각 버튼은 올바른 href 를 가진다', () => {
const qmBoards = findById(quickMenuExt as any, 'qm_boards');
const qmReports = findById(quickMenuExt as any, 'qm_reports');
expect(qmBoards.props.href).toBe('/admin/boards');
expect(qmReports.props.href).toBe('/admin/boards/reports');
});
it('각 버튼은 Icon + Span 자식을 가진다', () => {
for (const btnId of ['qm_boards', 'qm_reports']) {
const btn = findById(quickMenuExt as any, btnId);
const icons = findByName(btn, 'Icon');
const spans = findByName(btn, 'Span');
expect(icons).toHaveLength(1);
expect(spans).toHaveLength(1);
}
});
it('다국어 키는 sirsoft-board 네임스페이스를 사용한다', () => {
const tKeys = extractTKeys(quickMenuExt);
expect(tKeys).toContain('sirsoft-board.admin.dashboard.quick_menu.boards');
expect(tKeys).toContain('sirsoft-board.admin.dashboard.quick_menu.reports');
});
});
describe('admin_dashboard_community.json - community 슬롯 주입', () => {
it('extension_point / mode / priority 가 올바르다', () => {
expect(communityExt.extension_point).toBe('admin_dashboard_community');
expect(communityExt.mode).toBe('append');
expect(communityExt.priority).toBe(100);
});
it('data_sources 4개(overview/post-graph/recent-posts/pending-reports)를 정의한다', () => {
expect(communityExt.data_sources).toHaveLength(4);
const ids = communityExt.data_sources.map((d: any) => d.id);
expect(ids).toEqual([
'board_overview',
'board_post_graph',
'board_recent_posts',
'board_pending_reports',
]);
});
it('각 data_source 는 모듈 API 엔드포인트를 호출한다', () => {
const endpoints = communityExt.data_sources.map((d: any) => d.endpoint);
expect(endpoints).toEqual([
'/api/modules/sirsoft-board/admin/dashboard/overview',
'/api/modules/sirsoft-board/admin/dashboard/post-graph',
'/api/modules/sirsoft-board/admin/dashboard/recent-posts',
'/api/modules/sirsoft-board/admin/dashboard/pending-reports',
]);
});
it('각 data_source 는 auth_required=true 와 fallback 을 가진다', () => {
for (const ds of communityExt.data_sources) {
expect(ds.auth_required).toBe(true);
expect(ds.method).toBe('GET');
expect(ds.fallback).toBeDefined();
}
});
it('community_section_wrapper 안에 ExtensionBadge + 카드 4종을 주입한다', () => {
const wrapper = findById(communityExt as any, 'community_section_wrapper');
expect(wrapper).not.toBeNull();
const badges = findByName(wrapper, 'ExtensionBadge');
expect(badges).toHaveLength(1);
expect(badges[0].props.type).toBe('module');
expect(badges[0].props.identifier).toBe('sirsoft-board');
});
it('카드 4종(오늘 배지/추세 그래프/최신글/미처리 신고) 이 존재한다', () => {
expect(findById(communityExt as any, 'post_graph_today_summary')).not.toBeNull();
expect(findById(communityExt as any, 'post_graph_card')).not.toBeNull();
expect(findById(communityExt as any, 'latest_posts_card')).not.toBeNull();
expect(findById(communityExt as any, 'report_management_card')).not.toBeNull();
});
it('오늘 배지는 board_overview 응답을 바인딩한다', () => {
const today = findById(communityExt as any, 'today_new_posts_badge');
expect(today.text).toContain('board_overview?.data?.today_posts');
const comments = findById(communityExt as any, 'today_new_comments_badge');
expect(comments.text).toContain('board_overview?.data?.today_comments');
});
it('총 게시글/댓글 값은 board_post_graph 응답을 바인딩한다', () => {
const totalPosts = findById(communityExt as any, 'total_posts_value');
expect(totalPosts.text).toContain('board_post_graph?.data?.total_posts');
const totalComments = findById(communityExt as any, 'total_comments_value');
expect(totalComments.text).toContain('board_post_graph?.data?.total_comments');
});
it('변화율은 데이터 부족 시 — 폴백을 가진다', () => {
const postsChange = findById(communityExt as any, 'total_posts_change');
expect(postsChange.text).toContain("'—'");
expect(postsChange.text).toContain('posts_change');
const commentsChange = findById(communityExt as any, 'total_comments_change');
expect(commentsChange.text).toContain("'—'");
expect(commentsChange.text).toContain('comments_change');
});
it('BarChart 는 board_post_graph.days 를 변환하여 labels/datasets 을 생성한다', () => {
const chart = findById(communityExt as any, 'post_graph_chart');
expect(chart.name).toBe('BarChart');
expect(chart.props.labels).toContain('board_post_graph?.data?.days');
expect(chart.props.datasets).toContain('post_count');
expect(chart.props.datasets).toContain('comment_count');
});
it('최신 게시글 항목(latest_post_item)에 iteration 이 정의된다', () => {
// iteration 은 wrapper(latest_posts_list) 가 아니라 item(latest_post_item) 에 둔다.
// wrapper 에 iteration 을 두면 wrapper 가 각 행마다 통째로 복제되어 divide-y > * 가
// 형제 관계를 잃고 구분선이 사라진다.
// Controller 가 ResponseHelper::moduleSuccess(MODULE, key, RecentPostResource::collection($posts)) 로 호출하면
// Laravel 은 ResourceCollection 을 평면 배열로 toArray() 하여 data 키 아래 그대로 넣는다.
// 따라서 실제 응답은 { success, message, data: [...] } 이고, 바인딩은 data.data 가 아닌 data 다.
const list = findById(communityExt as any, 'latest_posts_list');
expect(list.iteration).toBeUndefined();
const item = findById(communityExt as any, 'latest_post_item');
expect(item.iteration).toBeDefined();
expect(item.iteration.source).toBe('board_recent_posts?.data');
expect(item.iteration.item_var).toBe('post');
expect(item.iteration.index_var).toBe('i');
});
it('미처리 신고 항목(report_item)에 iteration 이 정의된다', () => {
const list = findById(communityExt as any, 'report_management_list');
expect(list.iteration).toBeUndefined();
const item = findById(communityExt as any, 'report_item');
expect(item.iteration).toBeDefined();
expect(item.iteration.source).toBe('board_pending_reports?.data?.items');
expect(item.iteration.item_var).toBe('report');
expect(item.iteration.index_var).toBe('i');
});
it('빈 상태 메시지는 length === 0 조건으로 노출된다', () => {
const postsEmpty = findById(communityExt as any, 'latest_posts_empty');
expect(postsEmpty.if).toBe('{{(board_recent_posts?.data ?? []).length === 0}}');
const reportsEmpty = findById(communityExt as any, 'report_management_empty');
expect(reportsEmpty.if).toBe('{{(board_pending_reports?.data?.items ?? []).length === 0}}');
});
it('"전체 보기" 링크가 정정된 경로 + 도착지 명칭(게시판 관리 / 신고 관리)을 사용한다', () => {
const postsViewAll = findById(communityExt as any, 'latest_posts_view_all');
expect(postsViewAll.props.href).toBe('/admin/boards');
expect(postsViewAll.text).toBe('$t:sirsoft-board.admin.dashboard.community.go_to_boards');
const reportsViewAll = findById(communityExt as any, 'report_management_view_all');
expect(reportsViewAll.props.href).toBe('/admin/boards/reports');
expect(reportsViewAll.text).toBe('$t:sirsoft-board.admin.dashboard.community.go_to_reports');
});
it('최신 게시글 항목은 클릭 시 게시글 상세로 navigate 한다', () => {
const item = findById(communityExt as any, 'latest_post_item');
expect(Array.isArray(item.actions)).toBe(true);
const click = item.actions.find((a: any) => a.type === 'click' && a.handler === 'navigate');
expect(click).toBeDefined();
expect(click.params.path).toBe('/admin/board/{{post?.board_slug}}/post/{{post?.id}}');
});
it('신고 항목은 클릭 시 신고 상세로 navigate 한다', () => {
const item = findById(communityExt as any, 'report_item');
expect(Array.isArray(item.actions)).toBe(true);
const click = item.actions.find((a: any) => a.type === 'click' && a.handler === 'navigate');
expect(click).toBeDefined();
expect(click.params.path).toBe('/admin/boards/reports/{{report?.id}}');
});
it('신고 카드는 신고 처리상태 배지(report_status_badge)를 노출하고 신고현황과 동일한 색상으로 분기한다', () => {
// 카드는 Pending/Review 신고만 표시한다 (ReportRepository::getPendingAcrossBoards).
// 배지는 신고 대상(게시글/댓글)의 상태가 아니라 신고 건 자체의 처리상태(status)를 보여준다.
// 색상은 신고현황 페이지(admin_board_reports_index 의 status 컬럼) 와 동일한 매핑을 사용한다.
// PO 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
const statusBadge = findById(communityExt as any, 'report_status_badge');
expect(statusBadge).not.toBeNull();
expect(statusBadge.if).toBe('{{!!report?.status_label}}');
expect(statusBadge.text).toBe('{{report?.status_label ?? \'\'}}');
const cls = statusBadge.props.className;
// 신고현황 페이지와 동일한 5색 + 기본 회색 매핑
expect(cls).toContain("bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"); // pending
expect(cls).toContain("bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"); // review
expect(cls).toContain("bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"); // rejected
expect(cls).toContain("bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"); // suspended
expect(cls).toContain("bg-gray-600 text-white dark:bg-gray-600 dark:text-gray-100"); // deleted
// 기존 대상상태(target_status) 분기 흔적이 남아 있지 않아야 한다 (회귀 가드)
expect(cls).not.toContain('target_status');
expect(cls).not.toContain('published');
expect(cls).not.toContain('blinded');
});
it('신고 카드의 타입 배지(report_target_type_badge)는 댓글/게시글 색상이 다르다', () => {
const typeBadge = findById(communityExt as any, 'report_target_type_badge');
expect(typeBadge.props.className).toContain("comment");
expect(typeBadge.props.className).toContain('bg-purple-900');
expect(typeBadge.props.className).toContain('bg-blue-900');
});
it('3 카드(post_graph_card / latest_posts_card / report_management_card) 가 h-full 로 동일 높이를 보장한다', () => {
for (const id of ['post_graph_card', 'latest_posts_card', 'report_management_card']) {
const card = findById(communityExt as any, id);
expect(card.props.className, `${id} 에 h-full 누락`).toContain('h-full');
}
});
it('갱신 시각 캡션은 updated_at_display 가 있을 때만 노출되고 백엔드 포맷 결과를 그대로 표시한다', () => {
// 캡션은 게시판 환경설정(display.date_display_format) 을 백엔드에서 적용한 결과(updated_at_display) 를 그대로 표시한다.
// 프론트에서 slice/format 가공을 하지 않는다 — 게시글 created_at 표시 규칙과 동일 컨벤션 유지.
const caption = findById(communityExt as any, 'post_graph_updated_at_caption');
expect(caption).not.toBeNull();
expect(caption.if).toBe('{{!!board_post_graph?.data?.updated_at_display}}');
expect(caption.text).toBe(
"$t:sirsoft-board.admin.dashboard.community.updated_at|time={{board_post_graph?.data?.updated_at_display ?? ''}}",
);
});
it('모든 다국어 키는 sirsoft-board 네임스페이스 또는 코어 공용 키를 사용한다', () => {
const tKeys = extractTKeys(communityExt);
for (const key of tKeys) {
const ok =
key.startsWith('sirsoft-board.') ||
key === 'admin.dashboard.stats.today';
expect(ok, `다국어 키 형식 위반: ${key}`).toBe(true);
}
});
});
@@ -0,0 +1,98 @@
/**
* @file board-copy-modal-info.test.tsx
* @description 게시판 복사 확인 모달 - 안내 박스(배지 박스 + 불릿 3항목) 레이아웃 JSON 구조 검증
*
* 이슈 #413-16 후속(PO 요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
* 새 슬러그 확인" 3항목으로 구조화. 안내 박스가 테두리+배경 컨테이너 안에 불릿 3개를
* 포함하는지 JSON 트리로 검증한다 (DOM 비의존 구조 검증).
*/
import { describe, it, expect } from 'vitest';
import indexLayout from '../../../layouts/admin/admin_board_index.json';
/**
* 트리에서 id 가 일치하는 첫 노드를 찾는다.
*/
function findById(node: any, id: string): any | null {
if (!node) return null;
if (node.id === id) return node;
const kids = node.children ?? node.modals ?? null;
if (Array.isArray(kids)) {
for (const child of kids) {
const found = findById(child, id);
if (found) return found;
}
}
// 최상위(layout) 객체는 modals/children 두 분기를 모두 탐색
if (Array.isArray(node.modals)) {
for (const child of node.modals) {
const found = findById(child, id);
if (found) return found;
}
}
return null;
}
/**
* 트리에서 text 가 특정 패턴을 포함하는 노드를 모두 찾는다.
*/
function findAllByText(node: any, pattern: string): any[] {
const results: any[] = [];
if (!node) return results;
if (typeof node.text === 'string' && node.text.includes(pattern)) results.push(node);
const kids = node.children ?? null;
if (Array.isArray(kids)) {
for (const child of kids) {
results.push(...findAllByText(child, pattern));
}
}
return results;
}
describe('게시판 복사 모달 - 안내 박스 (JSON 구조 검증)', () => {
it('안내 박스 컨테이너가 테두리+배경+다크모드 쌍을 갖는다', () => {
const box = findById(indexLayout, 'copy_info_box');
expect(box).not.toBeNull();
expect(box.name).toBe('Div');
const cls = box.props?.className ?? '';
// 테두리 + 배경 + 다크모드 라이트/다크 쌍 (layout-json 규칙: light/dark 쌍 필수)
expect(cls).toContain('border');
expect(cls).toContain('bg-gray-50');
expect(cls).toContain('dark:bg-gray-800');
expect(cls).toContain('dark:border-gray-700');
});
it('안내 박스 안에 불릿 3항목(복사됨/제외/슬러그)이 모두 존재한다', () => {
const box = findById(indexLayout, 'copy_info_box');
expect(box).not.toBeNull();
const copied = findById(box, 'copy_info_copied');
const excluded = findById(box, 'copy_info_excluded');
const slug = findById(box, 'copy_info_slug');
expect(copied).not.toBeNull();
expect(excluded).not.toBeNull();
expect(slug).not.toBeNull();
// 메모리 규칙: 운영자 안내 박스는 3항목 이하 — 정확히 3개
expect(box.children?.length).toBe(3);
});
it('각 불릿이 올바른 i18n 키 텍스트를 참조한다', () => {
const box = findById(indexLayout, 'copy_info_box');
expect(findAllByText(box, 'modals.copy.info_copied').length).toBe(1);
expect(findAllByText(box, 'modals.copy.info_excluded').length).toBe(1);
expect(findAllByText(box, 'modals.copy.info_slug').length).toBe(1);
});
it('이전 단일 안내 키(info)는 더 이상 사용되지 않는다 (회귀 가드)', () => {
const modal = findById(indexLayout, 'copy_confirm_modal');
expect(modal).not.toBeNull();
// 분리 전 단일 키 'modals.copy.info' 직접 참조가 남아있지 않아야 함
const stale = findAllByText(modal, 'modals.copy.info"');
expect(stale.length).toBe(0);
});
});
@@ -8,7 +8,6 @@
* - _tab_basic.json (게시판 폼 — board_types 옵션 라벨)
* - _tab_board_settings_basic.json (게시판 설정 — board_types 옵션 라벨)
* - _board_type_manage_modal.json (게시판 타입 관리 모달 — 이름 표시)
* - _tab_basic_defaults.json (기본값 탭 — board_types 옵션 라벨)
*
* 참조 패턴: sirsoft-page admin_page_detail.json 의 `_local.lang ?? $locale ?? 'ko'`
*/
@@ -18,13 +17,11 @@ import { describe, it, expect } from 'vitest';
import tabBasic from '../../../layouts/admin/partials/admin_board_form/_tab_basic.json';
import tabBoardSettingsBasic from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_basic.json';
import boardTypeManageModal from '../../../layouts/admin/partials/admin_board_form/_board_type_manage_modal.json';
import tabBasicDefaults from '../../../layouts/admin/partials/admin_board_settings/_tab_basic_defaults.json';
const layouts: Array<[string, unknown]> = [
['_tab_basic.json', tabBasic],
['_tab_board_settings_basic.json', tabBoardSettingsBasic],
['_board_type_manage_modal.json', boardTypeManageModal],
['_tab_basic_defaults.json', tabBasicDefaults],
];
describe('게시판 모듈 — locale fallback 하드코딩 회귀 가드', () => {
@@ -0,0 +1,90 @@
/**
* 게시판 환경설정 — 숫자 필드 범위 힌트 동적 바인딩 회귀 테스트 (이슈 #413)
*
* 배경: 환경설정 "게시판 설정" 탭의 숫자 입력 필드는 description 에 허용 범위를
* `({{min}}~{{max}})` 형태로 표시한다. 범위 값은 `config('sirsoft-board.limits')` 를
* SSoT 로 하여 BoardSettingsController.index() 가 `data._meta.limits` 로 노출하고,
* 데이터소스 `settings`(initLocal "form") 를 통해 `_local.form._meta.limits` 로 바인딩된다.
*
* 회귀 차단 포인트:
* 1. description text 가 `$t:...|min={{_local?.form?._meta?.limits?.{f}_min ?? N}}|max={{...}}`
* 파라미터를 포함해야 한다. (파라미터 누락 시 lang 의 `{{min}}`/`{{max}}` 가 raw 노출)
* 2. 경로는 코어 전역 `_global.settings` 와 ID 충돌하는 `settings.data._meta` 가 아닌
* `_local.form._meta` 여야 한다. (충돌 시 fallback 으로만 표시되거나 raw 노출)
* 3. Input 의 min/max 도 동일 동적 표현식이어야 한다. (하드코딩 시 config 변경과 어긋남)
*
* 실제 사용 파일(부모 admin_board_settings.json 이 partial 로 include):
* _tab_board_settings_list / _post / _reply / _comment / _attachment
* (구 _tab_basic_defaults.json 은 더 이상 사용하지 않음 — 삭제됨)
*/
import { describe, it, expect } from 'vitest';
import tabList from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_list.json';
import tabPost from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_post.json';
import tabReply from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_reply.json';
import tabComment from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_comment.json';
import tabAttachment from '../../../layouts/admin/partials/admin_board_settings/_tab_board_settings_attachment.json';
/**
* [필드명, partial, limits 키] 매핑.
* limits 키는 description/Input 이 참조하는 `_meta.limits.{키}_min/max` 의 키 prefix.
* per_page_mobile 은 per_page 와 동일한 limits 범위(per_page_min/max)를 공유한다.
*/
const fields: Array<[string, unknown, string]> = [
['per_page', tabList, 'per_page'],
['per_page_mobile', tabList, 'per_page'],
['min_title_length', tabPost, 'min_title_length'],
['max_title_length', tabPost, 'max_title_length'],
['min_content_length', tabPost, 'min_content_length'],
['max_content_length', tabPost, 'max_content_length'],
['max_reply_depth', tabReply, 'max_reply_depth'],
['min_comment_length', tabComment, 'min_comment_length'],
['max_comment_length', tabComment, 'max_comment_length'],
['max_comment_depth', tabComment, 'max_comment_depth'],
['max_file_size', tabAttachment, 'max_file_size'],
['max_file_count', tabAttachment, 'max_file_count'],
];
describe('게시판 환경설정 — 범위 힌트 동적 바인딩 가드 (#413)', () => {
it.each(fields)(
'%s — description text 가 _local.form._meta.limits 파라미터를 포함함',
(field, layout, limitsKey) => {
const str = JSON.stringify(layout);
// description 키에 |min=.../|max=... 파라미터가 _local.form._meta.limits 경로로 존재
expect(str).toMatch(
new RegExp(
`descriptions\\.${field}\\|min=\\{\\{_local\\?\\.form\\?\\._meta\\?\\.limits\\?\\.${limitsKey}_min`,
),
);
expect(str).toMatch(
new RegExp(`max=\\{\\{_local\\?\\.form\\?\\._meta\\?\\.limits\\?\\.${limitsKey}_max`),
);
},
);
it.each(fields)(
'%s — Input min/max 가 하드코딩 정수가 아닌 _local.form._meta.limits 동적 표현식임',
(_field, layout, limitsKey) => {
const str = JSON.stringify(layout);
// Input 의 min/max 가 동적 표현식 (하드코딩 정수 금지)
expect(str).toMatch(
new RegExp(`"min":\\s*"\\{\\{_local\\?\\.form\\?\\._meta\\?\\.limits\\?\\.${limitsKey}_min`),
);
expect(str).toMatch(
new RegExp(`"max":\\s*"\\{\\{_local\\?\\.form\\?\\._meta\\?\\.limits\\?\\.${limitsKey}_max`),
);
},
);
it.each(fields)(
'%s — 코어 전역과 충돌하는 settings.data._meta 경로를 사용하지 않음',
(_field, layout, limitsKey) => {
const str = JSON.stringify(layout);
// settings?.data?._meta 경로는 _global.settings(코어 사이트 설정)와 ID 충돌 → 금지
expect(str).not.toMatch(
new RegExp(`settings\\?\\.data\\?\\._meta\\?\\.limits\\?\\.${limitsKey}`),
);
},
);
});
@@ -480,9 +480,10 @@ describe('게시판 유형 관리 모달 레이아웃', () => {
expect(tbody.props.className).toContain('dark:bg-gray-800');
});
it('헤더 셀에 다크 모드 텍스트 색상이 있다', () => {
it('헤더 셀에 다크 모드 텍스트 색상이 있다 (#408 — .text-table-header 시맨틱이 흡수)', () => {
const thSlug = findById(modal, 'bt_th_slug');
expect(thSlug.props.className).toContain('dark:text-gray-400');
// text-xs + text-gray-500 + dark:text-gray-400 + font-medium + uppercase + tracking-wider 토큰을 .text-table-header 자산이 흡수
expect(thSlug.props.className).toContain('text-table-header');
});
it('데이터 행에 다크 모드 호버 효과가 있다', () => {
@@ -529,32 +530,32 @@ describe('게시판 유형 관리 모달 레이아웃', () => {
});
describe('폼 유효성 에러 표시', () => {
it('slug Input에 에러 시 빨간 테두리를 위한 classMap이 있다', () => {
it('slug Input에 에러 시 input-error 시맨틱이 분기 적용된다', () => {
const slugInput = findById(modal, 'bt_input_slug');
expect(slugInput.classMap).toBeDefined();
expect(slugInput.classMap.key).toContain('btFormErrors?.slug');
expect(slugInput.classMap.variants.error).toContain('border-red-500');
expect(slugInput.classMap.variants.error).toContain('dark:border-red-400');
expect(slugInput.classMap.variants.normal).toContain('border-gray-300');
// Input 외형은 시맨틱 .input 이 디폴트로 제공, 에러는 input-error 토글로 통일 (#369)
expect(slugInput.classMap.variants.error).toBe('input-error');
expect(slugInput.classMap.variants.normal).toBe('');
});
it('slug 에러 메시지 요소가 존재하고 조건부 렌더링된다', () => {
it('slug 에러 메시지 요소가 존재하고 조건부 렌더링된다 (#408 — .form-error-xs 흡수)', () => {
const slugError = findById(modal, 'bt_slug_error');
expect(slugError).not.toBeNull();
expect(slugError.name).toBe('P');
expect(slugError.if).toContain('btFormErrors?.slug');
expect(slugError.text).toContain('btFormErrors?.slug?.[0]');
expect(slugError.props.className).toContain('text-red-500');
expect(slugError.props.className).toContain('dark:text-red-400');
// text-red-500 + dark:text-red-400 + text-xs 토큰을 .form-error-xs 자산이 흡수
expect(slugError.props.className).toContain('form-error-xs');
});
it('name 에러 메시지 요소가 존재하고 조건부 렌더링된다', () => {
it('name 에러 메시지 요소가 존재하고 조건부 렌더링된다 (#408 — .form-error-xs 흡수)', () => {
const nameError = findById(modal, 'bt_name_error');
expect(nameError).not.toBeNull();
expect(nameError.name).toBe('P');
expect(nameError.if).toContain("btFormErrors?.['name.ko']");
expect(nameError.text).toContain("btFormErrors?.['name.ko']?.[0]");
expect(nameError.props.className).toContain('text-red-500');
expect(nameError.props.className).toContain('form-error-xs');
});
it('MultilingualInput에 error prop이 전달된다', () => {
@@ -0,0 +1,239 @@
/**
* 게시판 도메인 편집기 샘플 데이터 계약 테스트
*
* 관리자 게시판 화면(게시글 목록/상세/글쓰기, 신고 목록/상세)은 sirsoft-board 모듈 레이아웃이며,
* 데이터소스를 선언한 레이아웃 소유 확장이 모듈이므로 편집기 샘플 SSoT 는
* 모듈 editor-spec(`editor-spec.json`)의 `sampleData.byDataSourceId` 다.
*
* 실제 Resource shape 대조:
* - posts : PostCollection::withBoardInfo (admin 권한 can_manage:true, ip 노출)
* - post : PostResource::toArray (admin — ip_address 노출 + comments/replies/attachments)
* - form_meta/form_data: posts/form-meta·form-data
* - reports : ReportCollection::withStatisticsAndPermissions (data[] = ReportResource, statistics, abilities)
* - report_detail : ReportDetailResource (post/comment + reporters[] 인라인 + histories[] + available_actions)
* - reporters_list : ReportLogResource[] (data[] + pagination)
*
* 바인딩 SSoT: resources/layouts/admin/{admin_board_posts_index,admin_board_post_detail,
* admin_board_post_form,admin_board_reports_index,admin_board_reports_detail}.json (+ partials).
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
function findProjectRoot(startDir: string): string {
let dir = startDir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'artisan'))) return dir;
dir = path.dirname(dir);
}
return path.resolve(startDir, '../../../../../../..');
}
const REPO_ROOT = findProjectRoot(__dirname);
const SPEC_PATH = path.join(REPO_ROOT, 'modules/_bundled/sirsoft-board/editor-spec.json');
const spec = JSON.parse(fs.readFileSync(SPEC_PATH, 'utf-8'));
const byId = spec.sampleData.byDataSourceId as Record<string, any>;
const states = spec.states as any;
function hasStub(node: unknown): boolean {
if (node === '샘플') return true;
if (typeof node === 'string' && /^샘플\s/.test(node)) return true;
if (Array.isArray(node)) {
if (node.length === 1 && node[0] === '샘플') return true;
return node.some(hasStub);
}
if (node && typeof node === 'object') return Object.values(node).some(hasStub);
return false;
}
describe('게시판 도메인 (board admin) — stub 0', () => {
for (const id of ['posts', 'post', 'form_meta', 'form_data', 'reports', 'report_detail', 'reporters_list']) {
it(`${id} 에 "샘플" stub 이 없다`, () => {
expect(byId[id]).toBeDefined();
expect(hasStub(byId[id])).toBe(false);
});
}
});
describe('posts (admin 목록) — 분기 양면 + admin 권한', () => {
const root = byId.posts.data;
it('목록 ≥3 + row_type 3종 + secret/deleted 분기 공존', () => {
expect(root.data.length).toBeGreaterThanOrEqual(3);
const types = root.data.map((p: any) => p.row_type);
expect(types).toContain('notice');
expect(types).toContain('normal');
expect(types).toContain('reply');
expect(root.data.some((p: any) => p.is_secret)).toBe(true);
expect(root.data.some((p: any) => p.deleted_at)).toBe(true);
});
it('admin abilities can_manage:true', () => {
expect(root.abilities.can_manage).toBe(true);
expect(root.abilities.can_view_deleted).toBe(true);
});
});
describe('post (admin 상세) — ip_address 노출 + 본체 채움', () => {
const d = byId.post.data;
it('ip_address 노출 + can_manage', () => {
expect(d.ip_address).toBeTruthy();
expect(d.abilities.can_manage).toBe(true);
});
it('comments ≥3 (depth/삭제 분기) + attachments(image/non-image) + replies', () => {
expect(d.comments.length).toBeGreaterThanOrEqual(3);
expect(d.comments.some((c: any) => c.depth >= 1)).toBe(true);
expect(d.comments.some((c: any) => c.deleted_at)).toBe(true);
expect(d.comments.every((c: any) => c.ip_address !== undefined)).toBe(true);
expect(d.attachments.some((a: any) => a.is_image)).toBe(true);
expect(d.attachments.some((a: any) => !a.is_image)).toBe(true);
expect(d.replies.length).toBeGreaterThanOrEqual(1);
});
});
describe('reports (신고 목록) — 상태 다양 + target_type 분기 + statistics', () => {
const root = byId.reports.data;
it('신고 ≥3 + status 다양(pending/review/rejected/suspended 중 ≥3종)', () => {
expect(root.data.length).toBeGreaterThanOrEqual(3);
const statuses = new Set(root.data.map((r: any) => r.status));
expect(statuses.size).toBeGreaterThanOrEqual(3);
});
it('target_type post/comment 분기 공존', () => {
const types = root.data.map((r: any) => r.target_type);
expect(types).toContain('post');
expect(types).toContain('comment');
});
it('각 행 필수 필드 + statistics + abilities', () => {
for (const r of root.data) {
for (const f of ['id', 'target_type', 'target_type_label', 'status', 'status_label', 'status_variant', 'reporter', 'reason_type_label', 'report_count', 'target_status_label', 'content_preview', 'created_at']) {
expect(r[f] !== undefined, `report row missing ${f}`).toBe(true);
}
}
expect(root.statistics.total).toBeGreaterThan(0);
expect(root.abilities.can_manage).toBe(true);
});
});
describe('report_detail (신고 상세) — reporters 인라인 + histories + available_actions', () => {
const d = byId.report_detail.data;
it('대상(post) + target_status_label + available_actions(복수)', () => {
expect(d.target_type).toBeTruthy();
expect(d.post?.id || d.comment?.id).toBeTruthy();
expect(d.target_status_label).toBeTruthy();
expect(Array.isArray(d.available_actions)).toBe(true);
expect(d.available_actions.length).toBeGreaterThanOrEqual(2);
});
it('reporters ≥3 (reason_detail null/값 분기) + 필수 필드', () => {
expect(d.reporters.length).toBeGreaterThanOrEqual(3);
expect(d.reporters.some((r: any) => r.reason_detail)).toBe(true);
expect(d.reporters.some((r: any) => r.reason_detail === null)).toBe(true);
for (const r of d.reporters) {
for (const f of ['id', 'reporter', 'reason_type', 'reason_type_label', 'reported_at']) {
expect(r[f] !== undefined, `reporter missing ${f}`).toBe(true);
}
}
});
it('histories ≥2 + reason_summary + report_count', () => {
expect(d.histories.length).toBeGreaterThanOrEqual(2);
for (const h of d.histories) {
for (const f of ['id', 'type', 'processor_name', 'created_at']) {
expect(h[f] !== undefined, `history missing ${f}`).toBe(true);
}
}
expect(d.reason_summary).toBeTruthy();
expect(d.report_count).toBeGreaterThanOrEqual(3);
expect(d.abilities.can_view).toBeDefined();
});
});
describe('reporters_list — ReportLogResource[] + pagination', () => {
const root = byId.reporters_list.data;
it('data ≥3 + pagination', () => {
expect(root.data.length).toBeGreaterThanOrEqual(3);
for (const f of ['total', 'from', 'to', 'per_page', 'current_page', 'last_page']) {
expect(root.pagination[f] !== undefined, `pagination missing ${f}`).toBe(true);
}
for (const r of root.data) {
expect(r.reporter?.name || r.reporter === null).toBeTruthy();
expect(r.reason_type_label).toBeDefined();
}
});
});
describe('states 정정 — read_secret_locked + write 폼 시드', () => {
const groups = states.groups as any[];
const findGroup = (m: string) => groups.find((g) => g.scope && g.scope.match === m);
it('read_secret_locked override 가 실제 PostResource shape (content=null, base 충실)', () => {
const sg = findGroup('/board/:slug/:id');
const locked = sg.items.find((it: any) => it.id === 'read_secret_locked');
const p = locked.sampleDataOverrides.byDataSourceId.post.data;
// base 통째 교체이므로 헤더 렌더 필드 보유 (붕괴 가드)
expect(p.title).toBeTruthy();
expect(p.created_at).toBeTruthy();
expect(p.author?.name).toBeTruthy();
expect(p.board?.name).toBeTruthy();
// 비밀글 잠김 핵심 분기
expect(p.is_secret).toBe(true);
expect(p.content).toBeNull();
expect(Array.isArray(p.comments)).toBe(true);
expect(p.comments.length).toBe(0);
});
it('write edit_existing 에 _local.form prefill 시드', () => {
const wg = findGroup('/board/:slug/write');
const edit = wg.items.find((it: any) => it.id === 'edit_existing');
expect(edit.initialState.route.id).toBe(1);
expect(edit.initialState.local.form.title).toBeTruthy();
expect(edit.initialState.local.form.content).toBeTruthy();
});
it('write create_mode 빈 폼 마커 + validation_failed formErrors 보존', () => {
const wg = findGroup('/board/:slug/write');
const create = wg.items.find((it: any) => it.id === 'create_mode');
expect(create.initialState.route.id).toBeNull();
expect(create.initialState.local.form).toEqual({});
const vf = wg.items.find((it: any) => it.id === 'validation_failed');
expect(vf.formErrors['_local.errors.title']).toBeDefined();
expect(vf.initialState.local.form.title).toBe('');
});
});
describe('토글 게이트 본체 상태 — 편집기 정적 시뮬레이션(클릭 불가)이라 상태로 ON 패치 필요', () => {
const groups = states.groups as any[];
const findGroup = (m: string) => groups.find((g) => g.scope && g.scope.match === m);
it('admin 게시글상세: 첨부·답글 펼침 상태가 showAttachments:true + collapsedReplies[root]:false 패치', () => {
const g = findGroup('*/admin/board/:slug/post/:id');
expect(g, 'admin 게시글상세 states 그룹이 실제 라우트 path 와 일치해야 함').toBeDefined();
const open = g.items.find((it: any) => it.id === 'attachments_replies_open');
expect(open.initialState.local.showAttachments).toBe(true);
// 대댓글 502(parent_id 501) 의 root=501 이 펼쳐져야 본체 렌더
expect(open.initialState.local.collapsedReplies['501']).toBe(false);
});
it('admin 게시글상세 default·open 둘 다 route.id=142 패치 (댓글섹션 `post.data.id == route.id` 게이트 충족)', () => {
const g = findGroup('*/admin/board/:slug/post/:id');
const def = g.items.find((it: any) => it.id === 'default');
const open = g.items.find((it: any) => it.id === 'attachments_replies_open');
// 샘플 post.data.id 와 동일해야 댓글 섹션이 렌더됨
expect(def.initialState.route.id).toBe(byId.post.data.id);
expect(open.initialState.route.id).toBe(byId.post.data.id);
});
it('admin post 샘플에 첨부 image/non-image 분기 + depth>0 대댓글이 실재 (상태로 보여줄 본체)', () => {
const d = byId.post.data;
expect(d.attachments.some((a: any) => a.is_image)).toBe(true);
expect(d.attachments.some((a: any) => !a.is_image)).toBe(true);
const reply = d.comments.find((c: any) => c.depth >= 1);
expect(reply, 'depth>0 대댓글 본체가 샘플에 있어야 펼침 상태가 의미를 가짐').toBeDefined();
// commentRootMap 산식: depth>0 댓글의 parent_id 가 root id 가 됨
expect(reply.parent_id).toBe(501);
});
it('basic 게시글상세: 답글 펼침 상태가 collapsedReplies[root]:false 패치', () => {
const g = findGroup('/board/:slug/:id');
const open = g.items.find((it: any) => it.id === 'replies_expanded');
expect(open, 'basic 게시글상세 답글 펼침 상태').toBeDefined();
expect(open.initialState.local.collapsedReplies['501']).toBe(false);
});
});
@@ -0,0 +1,78 @@
/**
* 게시판 본인인증 정책 편집 모달 — 선언형 정책의 편집 가능/잠금 필드 회귀 테스트.
*
* 선언형 정책(source_type != admin)은 키(key)/시점(scope)/위치(target) 만 readonly 이며,
* 인증 목적(purpose)·적용 대상(applies_to) 을 포함한 그 외 필드는 운영자가 자유로이
* 편집할 수 있어야 한다. 과거 purpose/applies_to 까지 disabled 였던 회귀를 차단한다.
*/
import { describe, it, expect } from 'vitest';
const modalPartial = require('../../../layouts/admin/partials/admin_board_settings/_modal_identity_policy_form.json');
const LOCK_PREDICATE =
"!_global.identity_policy_form_modal?.isNew && _global.identity_policy_form_modal?.form?.source_type !== 'admin'";
function findFieldControl(node: any, labelKey: string): any | null {
if (!node || typeof node !== 'object') return null;
if (Array.isArray(node)) {
for (const item of node) {
const r = findFieldControl(item, labelKey);
if (r) return r;
}
return null;
}
if (Array.isArray(node.children)) {
const hasLabel = node.children.some((c: any) => c?.name === 'Label' && c?.text === labelKey);
if (hasLabel) {
const control = node.children.find((c: any) => c?.name === 'Input' || c?.name === 'Select');
if (control) return control;
}
}
for (const k of Object.keys(node)) {
const r = findFieldControl(node[k], labelKey);
if (r) return r;
}
return null;
}
describe('게시판 본인인증 정책 모달 — 편집 가능/잠금 필드', () => {
const lockedFields = [
['정책 키', '$t:admin.identity.policies.form.key'],
['강제 시점', '$t:admin.identity.policies.form.scope'],
['강제 위치', '$t:admin.identity.policies.form.target'],
] as const;
it.each(lockedFields)('선언형 잠금 필드 "%s" 는 disabled 바인딩 보유', (_label, labelKey) => {
const control = findFieldControl(modalPartial, labelKey);
expect(control).not.toBeNull();
expect(control!.props?.disabled).toBe(`{{${LOCK_PREDICATE}}}`);
});
const editableFields = [
['인증 목적', '$t:admin.identity.policies.form.purpose'],
['적용 대상', '$t:admin.identity.policies.form.applies_to'],
] as const;
it.each(editableFields)('편집 가능 필드 "%s" 는 disabled 미부여 (선언형도 편집 가능)', (_label, labelKey) => {
const control = findFieldControl(modalPartial, labelKey);
expect(control).not.toBeNull();
expect(control!.props?.disabled).toBeUndefined();
});
it('purpose Select 옵션은 전체 purpose 목록 매핑 (필터 없이 inicis.adult_verification 포함 가능)', () => {
const purpose = findFieldControl(modalPartial, '$t:admin.identity.policies.form.purpose');
expect(purpose!.props?.options).toContain('boardIdentityPurposes');
expect(purpose!.props?.options).not.toContain('filter');
});
it('우선순위(priority) 입력칸이 존재하고 form.priority 에 바인딩된다', () => {
const priority = findFieldControl(modalPartial, '$t:admin.identity.policies.form.priority');
expect(priority).not.toBeNull();
expect(priority!.name).toBe('Input');
expect(priority!.props?.type).toBe('number');
expect(priority!.props?.value).toContain('identity_policy_form_modal?.form?.priority');
// 선언형 정책도 우선순위는 편집 가능 (disabled 미부여)
expect(priority!.props?.disabled).toBeUndefined();
});
});
@@ -1,7 +1,8 @@
// e2e:allow 관리자 설정 전용 partial(출처필터 제거·목적박스 유지)은 레이아웃 렌더 테스트로 검증, develop 출시 동작 머지 복구라 신규 E2E 불요
/**
* 본인인증 정책 데이터소스/네비게이션의 source_identifier 형식 회귀 테스트.
*
* 회귀 사례 (#297): 모듈 본인인증 정책 탭에서 데이터소스가
* 회귀 사례: 모듈 본인인증 정책 탭에서 데이터소스가
* `source_identifier: "module:sirsoft-board"` (잘못된 접두사 포함) 으로 필터하여
* DB 의 `source_identifier='sirsoft-board'` (모듈 식별자만 저장) 와 매칭되지 않아
* 정책 목록이 항상 비어있던 문제.
@@ -28,7 +29,20 @@ function findValuesByKey(node: any, targetKey: string, results: string[] = []):
return results;
}
describe('게시판 본인인증 정책 — source_identifier 형식 회귀 (#297)', () => {
function findNodes(node: any, predicate: (n: any) => boolean, results: any[] = []): any[] {
if (!node || typeof node !== 'object') return results;
if (Array.isArray(node)) {
node.forEach((n) => findNodes(n, predicate, results));
return results;
}
if (predicate(node)) results.push(node);
for (const v of Object.values(node)) {
if (v && typeof v === 'object') findNodes(v, predicate, results);
}
return results;
}
describe('게시판 본인인증 정책 — source_identifier 형식 회귀', () => {
it('데이터소스 source_identifier 는 모듈 식별자만 사용 (module: 접두사 금지)', () => {
const ds = (settingsLayout.data_sources ?? []).find(
(d: any) => d.id === 'boardIdentityPolicies'
@@ -52,3 +66,66 @@ describe('게시판 본인인증 정책 — source_identifier 형식 회귀 (#29
.not.toContain(FORBIDDEN_PREFIX_VALUE);
});
});
describe('게시판 본인인증 정책 — 출처 필터 정리', () => {
const ds = (settingsLayout.data_sources ?? []).find(
(d: any) => d.id === 'boardIdentityPolicies'
);
it('데이터소스에 source_type 파라미터가 없다 (게시판 전용 고정 → 출처 필터 무의미)', () => {
expect(ds).toBeTruthy();
// source_identifier 게시판 고정은 유지, source_type 필터 파라미터는 제거
expect(ds.params).not.toHaveProperty('source_type');
expect(ds.params?.source_identifier).toBe(MODULE_IDENTIFIER);
});
it('데이터소스의 scope/search/purpose 필터는 유지된다 (회귀 방지)', () => {
expect(ds.params).toHaveProperty('scope');
expect(ds.params).toHaveProperty('search');
expect(ds.params).toHaveProperty('purpose');
});
it('filter_card 에 출처(source_type) Select 가 없다', () => {
const sourceTypeSelects = findNodes(
identityPoliciesPartial,
(n) => n?.name === 'Select' && n?.props?.name === 'filter.source_type'
);
expect(sourceTypeSelects).toHaveLength(0);
});
it('scope 필터 Select 와 search Input 은 유지된다 (회귀 방지)', () => {
const scopeSelects = findNodes(
identityPoliciesPartial,
(n) => n?.name === 'Select' && n?.props?.name === 'filter.scope'
);
const searchInputs = findNodes(
identityPoliciesPartial,
(n) => n?.name === 'Input' && n?.props?.name === 'filter.search'
);
expect(scopeSelects.length).toBeGreaterThan(0);
expect(searchInputs.length).toBeGreaterThan(0);
});
it('인증 목적 배지 카드(identity_purposes_card)는 유지되며 모듈 식별자로 필터한다', () => {
// 인증 목적 배지 카드는 유지한다. 모듈이 등록한 목적이 있으면 배지 표시, 없으면 빈 안내.
// 출처 필터 정리는 source_type Select 제거에 한정하고 카드 제거는 하지 않는다.
const purposeCards = findNodes(
identityPoliciesPartial,
(n) => n?.id === 'identity_purposes_card'
);
expect(purposeCards).toHaveLength(1);
// 박스의 배지 iteration 은 게시판 모듈이 등록한 목적만 필터해야 한다.
const iterationSources = findValuesByKey(purposeCards[0], 'source');
expect(iterationSources.some((s) => s.includes(`source_identifier === '${MODULE_IDENTIFIER}'`))).toBe(true);
// 목적이 없을 때 빈 안내 문구 분기가 존재한다.
const emptyNotices = findValuesByKey(purposeCards[0], 'text');
expect(emptyNotices.some((t) => t.includes('purposes_empty'))).toBe(true);
});
it('레이아웃 어디에도 query.source_type 잔여 참조가 없다', () => {
const serialized = JSON.stringify({ settingsLayout, identityPoliciesPartial });
expect(serialized).not.toContain('query.source_type');
});
});
@@ -73,6 +73,74 @@ describe('게시판 신고현황 named_actions 검증', () => {
});
});
describe('검색어 상태 스코프 정합 검증 (#413-72 회귀)', () => {
/**
* 검색 입력(Select/Input)의 setState 쓰기 스코프가, 검색 실행 액션 및
* value 바인딩의 읽기 스코프(_local)와 일치해야 한다.
*
* 결함: 입력 setState가 target:"global"로 _global 에 저장하는데,
* searchBoardReports 액션과 value 바인딩은 _local 을 읽어 검색어가 전달되지 않음.
*/
it('searchBoardReports가 _local.searchField / _local.searchQuery 를 읽어야 함', () => {
const action = (boardReports as any).named_actions.searchBoardReports;
expect(action.params.query['filters[0][field]']).toContain('_local.searchField');
expect(action.params.query['filters[0][value]']).toContain('_local.searchQuery');
});
it('검색 Select/Input의 value 바인딩이 _local 을 읽어야 함', () => {
for (const id of ['search_field_select', 'mobile_search_field_select']) {
const cmp = findComponentById(boardReports, id);
expect(cmp, `${id} 컴포넌트 존재`).toBeDefined();
expect(cmp.props.value).toContain('_local.searchField');
}
for (const id of ['search_input', 'mobile_search_input']) {
const cmp = findComponentById(boardReports, id);
expect(cmp, `${id} 컴포넌트 존재`).toBeDefined();
expect(cmp.props.value).toContain('_local.searchQuery');
}
});
it('검색 Select/Input의 change setState가 _local 에 저장해야 함 (target:"global" 금지)', () => {
const ids = [
'search_field_select',
'search_input',
'mobile_search_field_select',
'mobile_search_input',
];
for (const id of ids) {
const cmp = findComponentById(boardReports, id);
const changeAction = (cmp.actions || []).find(
(a: any) => a.type === 'change' && a.handler === 'setState',
);
expect(changeAction, `${id} 의 change setState 존재`).toBeDefined();
expect(
changeAction.params.target,
`${id}: 검색어 setState 는 _local 에 저장해야 함 (target:"global" 금지)`,
).not.toBe('global');
}
});
it('reset 버튼이 검색어를 _local 스코프로 초기화해야 함', () => {
const reset = findComponentById(boardReports, 'reset_button');
expect(reset).toBeDefined();
const setStates = (reset.actions || []).filter(
(a: any) => a.handler === 'setState',
);
// 검색어(searchField/searchQuery)를 초기화하는 setState 는 _local 대상이어야 함
const searchReset = setStates.find(
(a: any) =>
Object.prototype.hasOwnProperty.call(a.params, 'searchField') ||
Object.prototype.hasOwnProperty.call(a.params, 'searchQuery'),
);
expect(searchReset, '검색어 초기화 setState 존재').toBeDefined();
expect(
searchReset.params.target,
'reset: 검색어 초기화는 _local 대상이어야 함 (target:"global" 금지)',
).not.toBe('global');
});
});
describe('모바일 Dropdown 일괄 처리 검증', () => {
it('mobile_bulk_action_dropdown의 items가 value 필드를 가져야 함 (key 금지)', () => {
const dropdown = findComponentById(boardReports, 'mobile_bulk_action_dropdown');
@@ -0,0 +1,135 @@
/**
* 게시판 알림 설정 — 관리자 회원수정 폼(admin_user_form) 주입 정합성 회귀 테스트
*
* @description
* 회귀 배경 (#413-10 검수 중 발견):
* - user-notification-settings.json 의 주입 target_id 가 옛 슬롯 `extension_slot` 을
* 가리켜, 활성 회원수정 폼 레이아웃(현재 슬롯 = user_form_tabs + extension_form_content)에서
* 알림 토글 4개가 전혀 렌더되지 않던 결함.
* - 같은 슬롯을 쓰는 sirsoft-marketing 플러그인(탭 + extension_form_content)은 정상 렌더됨 → 대조로 확정.
*
* 검증 포인트:
* 1. 주입 target_id 가 활성 회원폼 레이아웃에 실제 존재하는 슬롯 ID 와 일치 (extension_slot 회귀 가드)
* 2. 탭 주입(user_form_tabs _append) + 내용 주입(extension_form_content) 2단 구조
* 3. 내용 래퍼의 props.id 가 탭 id(ext_notification)와 연결되는 앵커
* 4. 알림 토글 4개(notify_post_complete/post_reply/comment/reply_comment) 보존
* 5. 상세 화면(detail) 주입과 탭 id/라벨/아이콘 통일
*/
import { describe, it, expect } from 'vitest';
import notificationFormExt from '../../../extensions/user-notification-settings.json';
import notificationDetailExt from '../../../extensions/user-notification-detail.json';
import adminUserForm from '../../../../../../../templates/sirsoft-admin_basic/layouts/admin_user_form.json';
/**
* 트리에서 특정 ID 노드를 재귀 탐색합니다.
* 주입 JSON(components/children) 과 레이아웃 JSON(slots/content 등) 양쪽을 다루기 위해
* 모든 객체 키를 순회하는 범용 재귀를 사용합니다.
*/
function findById(node: any, id: string): any | null {
if (!node || typeof node !== 'object') return null;
if (node.id === id) return node;
for (const key of Object.keys(node)) {
const found = findById(node[key], id);
if (found) return found;
}
return null;
}
/**
* 트리에서 특정 name 컴포넌트 모두를 찾습니다 (모든 객체 키 순회).
*/
function findByName(node: any, name: string): any[] {
const results: any[] = [];
if (!node || typeof node !== 'object') return results;
if (node.name === name) results.push(node);
for (const key of Object.keys(node)) {
results.push(...findByName(node[key], name));
}
return results;
}
describe('user-notification-settings.json - admin_user_form 주입 정합성', () => {
it('admin_user_form 레이아웃을 타겟한다', () => {
expect(notificationFormExt.target_layout).toBe('admin_user_form');
});
it('주입 target_id 들이 활성 회원폼 레이아웃에 실제 존재하는 슬롯과 일치한다 (extension_slot 회귀 가드)', () => {
const targetIds = notificationFormExt.injections.map((inj: any) => inj.target_id);
// 회귀 가드: 활성 회원폼에 없는 옛 슬롯 ID 를 다시 타겟하면 토글이 사라진다.
expect(targetIds).not.toContain('extension_slot');
// 모든 주입 타겟이 활성 레이아웃에 실제로 존재해야 한다.
for (const tid of targetIds) {
expect(findById(adminUserForm as any, tid), `회원폼 레이아웃에 슬롯 '${tid}' 없음`).not.toBeNull();
}
});
it('탭 주입 + 내용 주입 2단 구조를 가진다', () => {
const tabInj = notificationFormExt.injections.find((i: any) => i.target_id === 'user_form_tabs');
const contentInj = notificationFormExt.injections.find((i: any) => i.target_id === 'extension_form_content');
expect(tabInj).toBeDefined();
expect(contentInj).toBeDefined();
expect(tabInj.position).toBe('inject_props');
expect(contentInj.position).toBe('append_child');
});
it('탭 주입은 ext_notification 탭을 _append 한다', () => {
const tabInj = notificationFormExt.injections.find((i: any) => i.target_id === 'user_form_tabs');
const appended = tabInj.props.tabs._append;
expect(Array.isArray(appended)).toBe(true);
expect(appended[0].id).toBe('ext_notification');
expect(appended[0].label).toBe('$t:sirsoft-board.admin.users.form.sections.notification_settings');
});
it('내용 래퍼 props.id 가 탭 id(ext_notification)와 연결되는 앵커다', () => {
const contentInj = notificationFormExt.injections.find((i: any) => i.target_id === 'extension_form_content');
const wrapper = contentInj.components[0];
expect(wrapper.props.id).toBe('ext_notification');
// 탭 스크롤 앵커 클래스 (마케팅 플러그인과 동일 패턴)
expect(wrapper.props.className).toContain('scroll-mt-32');
});
it('알림 토글 4개가 보존된다', () => {
for (const fieldId of [
'field_notify_post_complete',
'field_notify_post_reply',
'field_notify_comment',
'field_notify_reply_comment',
]) {
expect(findById(notificationFormExt as any, fieldId), `토글 '${fieldId}' 누락`).not.toBeNull();
}
});
it('각 토글은 checkbox Input 과 _local.form 바인딩을 가진다', () => {
const names = ['notify_post_complete', 'notify_post_reply', 'notify_comment', 'notify_reply_comment'];
const inputs = findByName(notificationFormExt as any, 'Input').filter((c: any) => c.props?.type === 'checkbox');
expect(inputs).toHaveLength(4);
for (const name of names) {
const input = inputs.find((c: any) => c.props.name === name);
expect(input, `checkbox '${name}' 없음`).toBeDefined();
expect(input.props.checked).toBe(`{{_local.form?.${name} ?? false}}`);
}
});
it('ExtensionBadge 가 module/sirsoft-board 로 표기된다', () => {
const badges = findByName(notificationFormExt as any, 'ExtensionBadge');
expect(badges).toHaveLength(1);
expect(badges[0].props.type).toBe('module');
expect(badges[0].props.identifier).toBe('sirsoft-board');
});
});
describe('user-notification-settings.json - 상세(detail) 주입과 탭 통일', () => {
it('수정/상세 화면이 동일한 탭 id·라벨·아이콘을 사용한다', () => {
const formTab = notificationFormExt.injections.find((i: any) => i.target_id === 'user_form_tabs')
.props.tabs._append[0];
const detailTab = notificationDetailExt.injections.find((i: any) => i.target_id === 'user_detail_tabs')
.props.tabs._append[0];
expect(formTab.id).toBe(detailTab.id);
expect(formTab.label).toBe(detailTab.label);
expect(formTab.iconName).toBe(detailTab.iconName);
});
});
@@ -1,4 +1,26 @@
{
"editor": {
"data_source": {
"availableChannels": "Available Channels",
"boardIdentityPolicies": "Board Identity Policies",
"boardIdentityPurposes": "Board Identity Purposes",
"boardNotificationDefinitions": "Board Notification Definitions",
"board_list_for_inquiry": "Board List for Inquiry",
"board_types": "Board Types",
"boards": "Boards",
"boards_list": "Board List",
"form_data": "Form Data",
"form_meta": "Form Meta",
"identityProviders": "Identity Providers",
"post": "Post",
"posts": "Posts",
"report_detail": "Report Detail",
"reporters_list": "Reporters List",
"reports": "Reports",
"roles": "Roles",
"settings": "Board Settings"
}
},
"common": {
"$partial": "partial/en/common.json"
},
@@ -29,6 +51,9 @@
},
"ecommerce_settings": {
"$partial": "partial/en/admin/ecommerce_settings.json"
},
"dashboard": {
"$partial": "partial/en/admin/dashboard.json"
}
},
"board": {
@@ -35,6 +35,7 @@ return [
// Attachments
'board_attachment_upload' => 'Attachment uploaded (Post: :post_id)',
'board_attachment_delete' => 'Attachment deleted (Post: :post_id)',
'board_attachment_download' => 'Attachment downloaded (Post: :post_id)',
'board_attachment_reorder' => 'Attachment order changed (Post: :post_id)',
// Report management (Admin)
@@ -49,5 +50,6 @@ return [
// Board settings
'board_settings_index' => 'Board settings viewed',
'board_settings_bulk_apply' => 'Board settings bulk applied',
'board_settings_bulk_apply_aborted' => 'Board settings bulk apply aborted (full rollback, failed board: :failed_board_name, :failed_at/:total)',
],
];
@@ -1,4 +1,26 @@
{
"editor": {
"data_source": {
"availableChannels": "사용 가능 채널",
"boardIdentityPolicies": "게시판 본인인증 정책",
"boardIdentityPurposes": "게시판 본인인증 목적",
"boardNotificationDefinitions": "게시판 알림 정의",
"board_list_for_inquiry": "문의용 게시판 목록",
"board_types": "게시판 유형",
"boards": "게시판 목록",
"boards_list": "게시판 목록",
"form_data": "폼 데이터",
"form_meta": "폼 메타",
"identityProviders": "본인인증 제공자",
"post": "게시글",
"posts": "게시글 목록",
"report_detail": "신고 상세",
"reporters_list": "신고자 목록",
"reports": "신고 목록",
"roles": "역할 목록",
"settings": "게시판 설정"
}
},
"common": {
"$partial": "partial/ko/common.json"
},
@@ -29,6 +51,9 @@
},
"ecommerce_settings": {
"$partial": "partial/ko/admin/ecommerce_settings.json"
},
"dashboard": {
"$partial": "partial/ko/admin/dashboard.json"
}
},
"board": {
@@ -35,6 +35,7 @@ return [
// 첨부파일
'board_attachment_upload' => '첨부파일 업로드 (게시물: :post_id)',
'board_attachment_delete' => '첨부파일 삭제 (게시물: :post_id)',
'board_attachment_download' => '첨부파일 다운로드 (게시물: :post_id)',
'board_attachment_reorder' => '첨부파일 순서 변경 (게시물: :post_id)',
// 신고 관리 (Admin)
@@ -49,5 +50,6 @@ return [
// 게시판 설정
'board_settings_index' => '게시판 설정 조회',
'board_settings_bulk_apply' => '게시판 설정 일괄 적용',
'board_settings_bulk_apply_aborted' => '게시판 설정 일괄 적용 중단 (전체 롤백, 실패 게시판: :failed_board_name, :failed_at/:total)',
],
];
@@ -62,7 +62,7 @@
"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.",
"with_posts_info": "This board has {{posts_count}} post(s). Deleting it also permanently removes the comments and attachments (including the uploaded original files).",
"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)",
@@ -942,8 +942,8 @@
"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": "Blocked Keywords",
"blocked_keywords_placeholder": "Type a 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)",
@@ -1202,7 +1202,7 @@
"users": {
"form": {
"sections": {
"notification_settings": "Notification Settings"
"notification_settings": "Board Notification Settings"
},
"notifications": {
"post_complete": "Post Complete Notification",
@@ -0,0 +1,25 @@
{
"quick_menu": {
"boards": "Boards",
"reports": "Reports"
},
"today_badges": {
"new_posts": "{{count}} new posts",
"new_comments": "{{count}} new comments"
},
"community": {
"title": "Community",
"post_graph": "Post Activity",
"posts": "Posts",
"comments": "Comments",
"total_posts": "Total Posts",
"total_comments": "Total Comments",
"latest_posts": "Latest Posts",
"report_management": "Report Management",
"no_posts": "No posts yet",
"no_reports": "No reports",
"updated_at": "Updated: {{time}}",
"go_to_boards": "All boards",
"go_to_reports": "View all"
}
}
@@ -74,7 +74,7 @@
},
"blocked_keywords": {
"label": "Blocked Keywords",
"placeholder": "Comma-separated (e.g., spam, abuse, ads)",
"placeholder": "Type and press Enter (e.g., spam, abuse, ads)",
"description": "Inappropriate words or keywords to exclude"
},
"use_file_upload": {
@@ -92,8 +92,8 @@
},
"allowed_extensions": {
"label": "Allowed Extensions",
"placeholder": "Comma-separated (e.g., jpg, png, pdf)",
"description": "All extensions allowed if empty"
"placeholder": "Type and press Enter (e.g., jpg, png, pdf)",
"description": "Enter at least one allowed file extension"
},
"comment_order": {
"label": "Comment Sort Order",
@@ -203,19 +203,14 @@
"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_guide": "This board can be accessed via the paths below.",
"manual_admin_label": "Admin Menu Path",
"manual_user_label": "User Menu Path",
"example": "e.g.",
"add_to_admin_menu": "Add to Admin Menu",
"show_in_admin_menu": "Show in Admin Menu",
"show_in_admin_menu_desc": "Show this board in the admin menu. Applied on save, added under the [Board Management] menu.",
"menu_added_success": "Successfully added to admin menu.",
"menu_add_failed": "Failed to add menu."
}
@@ -2,7 +2,9 @@
"copy": {
"title": "Copy Board",
"message": "Would you like to copy '{{name}}' board?",
"info": "※ Only board settings will be copied, posts will not be copied.",
"info_copied": "Settings, permissions, and the manager list are copied together.",
"info_excluded": "Posts and comments are not copied.",
"info_slug": "Check the new slug in the form before saving.",
"new_slug_label": "New Slug",
"new_slug_placeholder": "e.g., notice-copy",
"confirm": "Copy"
@@ -10,7 +12,7 @@
"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.",
"with_posts_info": "This board has {{posts_count}} post(s). Deleting it also permanently removes the comments and attachments (including the uploaded original files).",
"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)",
@@ -242,6 +242,10 @@
"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.",
"delete_editor_label": "Delete post/comment",
"report_editor_label": "Report post/comment",
"blind_editor_label": "Blind post/comment",
"restore_editor_label": "Restore post/comment",
"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.",
@@ -86,6 +86,8 @@
"view_detail": "User Info"
},
"modals": {
"status_action_editor_label": "Change report status",
"status_change_editor_label": "Bulk change report status",
"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?",
@@ -124,12 +124,18 @@
"mail": "Email",
"database": "Site Notification"
},
"report_notification_template_status_title": "Delivery Status by Channel",
"report_notification_template_status_active": "Active",
"report_notification_template_status_inactive": "Inactive",
"report_notification_template_status_empty": "No notification templates are linked.",
"report_notification_template_status_hint": "Per-channel delivery, templates and recipients are managed in the Notification Settings menu.",
"report_notification_go_to_settings": "Go to Notification Settings",
"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": "Blocked Keywords",
"blocked_keywords_placeholder": "Type a 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)",
@@ -151,26 +157,26 @@
"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",
"per_page": "Number of posts to display per page on desktop ({{min}}–{{max}})",
"per_page_mobile": "Number of posts to display per page on mobile ({{min}}–{{max}})",
"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",
"min_title_length": "Set the minimum character count for post titles ({{min}}–{{max}})",
"max_title_length": "Set the maximum character count for post titles ({{min}}–{{max}})",
"min_content_length": "Set the minimum character count for post content ({{min}}–{{max}})",
"max_content_length": "Set the maximum character count for post content ({{min}}–{{max}})",
"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)",
"max_reply_depth": "Set the maximum nesting depth for replies ({{min}}–{{max}})",
"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)",
"min_comment_length": "Set the minimum character count for comments ({{min}}–{{max}})",
"max_comment_length": "Set the maximum character count for comments ({{min}}–{{max}})",
"max_comment_depth": "Set the maximum nesting depth for nested comments. Setting to 0 disables the nested comment feature ({{min}}–{{max}})",
"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"
"max_file_size": "Maximum file size for uploads (in MB, {{min}}–{{max}})",
"max_file_count": "Maximum number of files per post ({{min}}–{{max}})",
"allowed_extensions": "Enter at least one allowed file extension"
}
},
"options": {
@@ -313,7 +319,12 @@
"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."
"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.",
"aborted_title": "Bulk Apply Failed",
"aborted_message": "An error occurred while updating permissions for {{board}}, so all changes have been cancelled.",
"aborted_message_suffix": ": an error occurred while updating its permissions, so all changes have been cancelled.",
"aborted_message_generic": "An error occurred during bulk apply, so all changes have been cancelled.",
"aborted_toast": "Bulk apply failed, so all changes have been cancelled."
},
"seo": {
"meta_settings": {
@@ -402,6 +413,7 @@
}
},
"identity": {
"policy_form_editor_label": "Add/edit identity policy",
"intro_title": "Board Identity Verification Policies",
"intro": "Manage policies declared by the board module and policies added by operators. Module default policies allow only enabled/grace adjustments; operator-defined policies can be freely edited or removed in core settings.",
"purposes_section_title": "Identity Purposes Registered by This Module",
@@ -1,7 +1,7 @@
{
"form": {
"sections": {
"notification_settings": "Notification Settings"
"notification_settings": "Board Notification Settings"
},
"notifications": {
"post_complete": "Post Complete Notification",
@@ -62,7 +62,7 @@
"delete": {
"title": "게시판 삭제",
"confirm": "'{{name}}' 게시판을 삭제하시겠습니까?",
"with_posts_info": "이 게시판에는 {{posts_count}}개의 게시글과 댓글이 있습니다.",
"with_posts_info": "이 게시판에는 {{posts_count}}개의 게시글이 있습니다. 삭제 시 게시글에 달린 댓글과 첨부파일(업로드된 원본 파일 포함)도 함께 삭제됩니다.",
"no_posts_info": "이 게시판에는 게시글이 없습니다.",
"force_delete_label_with_posts": "영구 삭제 (게시글, 댓글, 첨부파일 모두 삭제)",
"force_delete_label_no_posts": "영구 삭제 (데이터베이스 테이블도 삭제)",
@@ -151,9 +151,9 @@
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"blocked_keywords": {
"label": "제한 키워드",
"placeholder": "쉼표로 구분하여 입력 (예: 욕설, 비방, 광고)",
"description": "부적절한 단어 또는 해당 게시판에 제외될 키워드"
"label": "금지어",
"placeholder": "금지어 입력 후 Enter (예: 욕설, 비방, 광고)",
"description": "부적절한 단어 또는 해당 게시판에서 제외할 금지어"
},
"use_file_upload": {
"label": "첨부 파일 설정",
@@ -942,9 +942,9 @@
"report_view_roles_description": "신고 목록과 상세 내용을 열람할 수 있는 권한입니다.",
"report_manage_roles": "신고 처리 권한",
"report_manage_roles_description": "신고를 승인·반려·블라인드 처리할 수 있는 권한입니다. 신고 접수 시 관리자 알림도 이 권한을 가진 역할 기준으로 발송됩니다.",
"blocked_keywords": "제한 키워드",
"blocked_keywords_placeholder": "키워드 입력 후 Enter",
"blocked_keywords_description": "게시글/댓글에서 차단할 키워드 목록입니다",
"blocked_keywords": "금지어",
"blocked_keywords_placeholder": "금지어 입력 후 Enter",
"blocked_keywords_description": "게시글/댓글에서 차단할 금지어 목록입니다",
"blocked_keywords_hint": "등록된 키워드가 포함된 게시글/댓글은 작성이 차단됩니다",
"post_cooldown_seconds": "게시글 쿨다운 (초)",
"post_cooldown_seconds_hint": "같은 사용자가 연속 게시글 작성 시 대기 시간 (0~3600초, 0 = 제한 없음)",
@@ -1202,7 +1202,7 @@
"users": {
"form": {
"sections": {
"notification_settings": "알림 설정"
"notification_settings": "게시판 알림 설정"
},
"notifications": {
"post_complete": "게시글 완료 알림",
@@ -0,0 +1,25 @@
{
"quick_menu": {
"boards": "게시판",
"reports": "신고"
},
"today_badges": {
"new_posts": "새게시물 {{count}}개",
"new_comments": "새댓글 {{count}}개"
},
"community": {
"title": "커뮤니티",
"post_graph": "게시물 현황",
"posts": "게시글",
"comments": "댓글",
"total_posts": "총 게시글",
"total_comments": "총 댓글",
"latest_posts": "최신 게시글",
"report_management": "신고 관리",
"no_posts": "등록된 게시글이 없습니다",
"no_reports": "신고된 콘텐츠가 없습니다",
"updated_at": "최근 갱신: {{time}}",
"go_to_boards": "모든 게시판",
"go_to_reports": "전체 보기"
}
}
@@ -73,9 +73,9 @@
"hint": "{{min}}~{{max}} 사이의 값을 입력하세요"
},
"blocked_keywords": {
"label": "제한 키워드",
"placeholder": "쉼표로 구분하여 입력 (예: 욕설, 비방, 광고)",
"description": "부적절한 단어 또는 해당 게시판에 제외될 키워드"
"label": "금지어",
"placeholder": "금지어 입력 후 Enter (예: 욕설, 비방, 광고)",
"description": "부적절한 단어 또는 해당 게시판에서 제외할 금지어"
},
"use_file_upload": {
"label": "첨부 파일 설정",
@@ -92,8 +92,8 @@
},
"allowed_extensions": {
"label": "허용 확장자",
"placeholder": "쉼표로 구분하여 입력 (예: jpg, png, pdf)",
"description": "빈 값일 경우 모든 확장자 허용"
"placeholder": "확장자 입력 후 Enter (예: jpg, png, pdf)",
"description": "허용할 파일 확장자를 최소 1개 이상 입력하세요"
},
"comment_order": {
"label": "댓글 정렬 순서",
@@ -203,19 +203,14 @@
"auto_role_notice": "게시판 관리자와 스텝 역할은 생성 시 각 권한에 기본값으로 포함되며, 아래에서 수정할 수 있습니다.",
"create_mode_permissions_notice": "역할별 접근 제어는 게시판 생성 후 수정 화면에서 설정할 수 있습니다. 생성 시에는 기본 권한이 자동으로 적용됩니다."
},
"modals": {
"category_remove": {
"title": "분류 삭제",
"warning": "이 작업은 되돌릴 수 없습니다",
"message": "'{{category}}' 분류를 삭제하시겠습니까?"
}
},
"menu": {
"manual_guide": "메뉴 관리에서 아래 경로를 입력하여 등록하세요.",
"manual_guide": "이 게시판은 아래 경로로 접근할 수 있습니다.",
"manual_admin_label": "관리자 메뉴 경로",
"manual_user_label": "사용자 메뉴 경로",
"example": "예시",
"add_to_admin_menu": "관리자 메뉴에 추가",
"show_in_admin_menu": "관리자 메뉴에 표시",
"show_in_admin_menu_desc": "이 게시판을 관리자 메뉴에 노출합니다. 저장 시 반영되며, [게시판 관리] 메뉴 아래에 추가됩니다.",
"menu_added_success": "관리자 메뉴에 추가되었습니다.",
"menu_add_failed": "메뉴 추가에 실패했습니다."
}
@@ -2,7 +2,9 @@
"copy": {
"title": "게시판 복사",
"message": "'{{name}}' 게시판을 복사하시겠습니까?",
"info": "※ 게시판 설정만 복사되며, 게시글은 복사되지 않습니다.",
"info_copied": "설정·권한·관리자 명단이 함께 복사됩니다.",
"info_excluded": "게시글·댓글은 복사되지 않습니다.",
"info_slug": "저장 전 폼에서 새 슬러그를 확인하세요.",
"new_slug_label": "새 슬러그",
"new_slug_placeholder": "예: notice-copy",
"confirm": "복사"
@@ -10,7 +12,7 @@
"delete": {
"title": "게시판 삭제",
"confirm": "'{{name}}' 게시판을 삭제하시겠습니까?",
"with_posts_info": "이 게시판에는 {{posts_count}}개의 게시글과 댓글이 있습니다.",
"with_posts_info": "이 게시판에는 {{posts_count}}개의 게시글이 있습니다. 삭제 시 게시글에 달린 댓글과 첨부파일(업로드된 원본 파일 포함)도 함께 삭제됩니다.",
"no_posts_info": "이 게시판에는 게시글이 없습니다.",
"force_delete_label_with_posts": "영구 삭제 (게시글, 댓글, 첨부파일 모두 삭제)",
"force_delete_label_no_posts": "영구 삭제 (데이터베이스 테이블도 삭제)",
@@ -245,6 +245,10 @@
"delete_title": "게시글 삭제",
"delete_confirm": "정말로 이 게시글을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"delete_success": "게시글이 성공적으로 삭제되었습니다.",
"delete_editor_label": "게시글/댓글 삭제",
"report_editor_label": "게시글/댓글 신고",
"blind_editor_label": "게시글/댓글 블라인드",
"restore_editor_label": "게시글/댓글 복원",
"blind_title": "게시글 블라인드",
"blind_comment_title": "댓글 블라인드",
"blind_confirm": "이 게시글을 블라인드 처리하시겠습니까? 블라인드된 게시글은 일반 사용자에게 표시되지 않습니다.",
@@ -86,6 +86,8 @@
"view_detail": "사용자 정보"
},
"modals": {
"status_action_editor_label": "신고 상태 변경",
"status_change_editor_label": "신고 상태 일괄 변경",
"status_change_pending_title": "접수로 변경",
"status_change_pending_confirm": "선택한 {{count}}개 항목을 접수 상태로 변경하시겠습니까?",
"status_change_pending_confirm_single": "이 신고를 접수 상태로 변경하시겠습니까?",
@@ -124,13 +124,19 @@
"mail": "이메일",
"database": "사이트 알림"
},
"report_notification_template_status_title": "채널별 발송 상태",
"report_notification_template_status_active": "활성",
"report_notification_template_status_inactive": "비활성",
"report_notification_template_status_empty": "연결된 알림 템플릿이 없습니다.",
"report_notification_template_status_hint": "채널별 발송 여부·템플릿·수신자는 알림 설정 메뉴에서 관리합니다.",
"report_notification_go_to_settings": "알림 설정으로 이동",
"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": "금지어",
"blocked_keywords_placeholder": "금지어 입력 후 Enter",
"blocked_keywords_description": "게시글\/댓글에서 차단할 금지어 목록입니다",
"blocked_keywords_hint": "등록된 키워드가 포함된 게시글\/댓글은 작성이 차단됩니다",
"post_cooldown_seconds": "게시글 쿨다운 (초)",
"post_cooldown_seconds_hint": "같은 사용자가 연속 게시글 작성 시 대기 시간 (0~3600초, 0 = 제한 없음)",
@@ -151,26 +157,26 @@
"show_view_count": "게시글 목록 및 상세에서 조회수를 표시합니다",
"use_report": "부적절한 게시글\/댓글 신고 기능을 활성화합니다",
"new_display_hours": "게시글 작성 후 [New] 배지를 표시할 기간 (시간 단위)",
"per_page": "PC 환경에서 한 페이지에 표시할 게시글 수입니다",
"per_page_mobile": "모바일 환경에서 한 페이지에 표시할 게시글 수입니다",
"per_page": "PC 환경에서 한 페이지에 표시할 게시글 수입니다 ({{min}}~{{max}})",
"per_page_mobile": "모바일 환경에서 한 페이지에 표시할 게시글 수입니다 ({{min}}~{{max}})",
"order_by": "게시글 목록의 기본 정렬 기준을 설정합니다",
"order_direction": "게시글 목록의 기본 정렬 방향을 설정합니다",
"min_title_length": "게시글 제목의 최소 글자 수를 설정합니다",
"max_title_length": "게시글 제목의 최대 글자 수를 설정합니다",
"min_content_length": "게시글 본문의 최소 글자 수를 설정합니다",
"max_content_length": "게시글 본문의 최대 글자 수를 설정합니다",
"min_title_length": "게시글 제목의 최소 글자 수를 설정합니다 ({{min}}~{{max}})",
"max_title_length": "게시글 제목의 최대 글자 수를 설정합니다 ({{min}}~{{max}})",
"min_content_length": "게시글 본문의 최소 글자 수를 설정합니다 ({{min}}~{{max}})",
"max_content_length": "게시글 본문의 최대 글자 수를 설정합니다 ({{min}}~{{max}})",
"use_reply": "ON으로 설정해야 새 게시판에 답변글 기능이 적용됩니다.",
"max_reply_depth": "답변글의 최대 중첩 깊이를 설정합니다 (1~5)",
"max_reply_depth": "답변글의 최대 중첩 깊이를 설정합니다 ({{min}}~{{max}})",
"blocked_keywords": "등록된 키워드가 포함된 게시글\/댓글은 작성이 차단됩니다",
"use_comment": "ON으로 설정해야 새 게시판에 댓글 기능이 적용됩니다. 아래 댓글 세부 설정도 함께 적용됩니다.",
"comment_order": "댓글의 기본 정렬 방향을 설정합니다",
"min_comment_length": "댓글의 최소 글자 수를 설정합니다",
"max_comment_length": "댓글의 최대 글자 수를 설정합니다",
"max_comment_depth": "대댓글의 최대 중첩 깊이를 설정합니다. 0으로 설정하면 대댓글 기능이 비활성화됩니다 (0~10)",
"min_comment_length": "댓글의 최소 글자 수를 설정합니다 ({{min}}~{{max}})",
"max_comment_length": "댓글의 최대 글자 수를 설정합니다 ({{min}}~{{max}})",
"max_comment_depth": "대댓글의 최대 중첩 깊이를 설정합니다. 0으로 설정하면 대댓글 기능이 비활성화됩니다 ({{min}}~{{max}})",
"use_file_upload": "ON으로 설정해야 새 게시판에 파일 첨부 기능이 적용됩니다.",
"max_file_size": "업로드 가능한 최대 파일 크기입니다 (MB 단위)",
"max_file_count": "게시글당 첨부 가능한 최대 파일 수입니다",
"allowed_extensions": "빈 값일 경우 모든 확장자가 허용됩니다"
"max_file_size": "업로드 가능한 최대 파일 크기입니다 (MB 단위, {{min}}~{{max}})",
"max_file_count": "게시글당 첨부 가능한 최대 파일 수입니다 ({{min}}~{{max}})",
"allowed_extensions": "허용할 파일 확장자를 최소 1개 이상 입력하세요"
}
},
"options": {
@@ -313,7 +319,12 @@
"select_boards_required": "게시판을 선택해 주세요.",
"save_and_apply_info": "확인 시 현재 탭의 설정 전체가 저장되며, 체크된 항목만 선택한 게시판에 일괄 적용됩니다.",
"save_error": "설정 저장에 실패했습니다. 일괄 적용을 중단합니다.",
"permission_notice": "선택한 권한 항목은 게시판의 기존 역할을 현재 설정값으로 교체합니다. 단, 해당 게시판의 관리자(manager)·스텝(step) 역할은 설정값 그대로 유지됩니다."
"permission_notice": "선택한 권한 항목은 게시판의 기존 역할을 현재 설정값으로 교체합니다. 단, 해당 게시판의 관리자(manager)·스텝(step) 역할은 설정값 그대로 유지됩니다.",
"aborted_title": "일괄 적용 실패",
"aborted_message": "{{board}} 게시판의 권한 수정 중 오류가 발생하여 전체 변경이 취소되었습니다.",
"aborted_message_suffix": " 게시판의 권한 수정 중 오류가 발생하여 전체 변경이 취소되었습니다.",
"aborted_message_generic": "일괄 적용 중 오류가 발생하여 전체 변경이 취소되었습니다.",
"aborted_toast": "일괄 적용에 실패하여 전체 변경이 취소되었습니다."
},
"seo": {
"meta_settings": {
@@ -402,6 +413,7 @@
}
},
"identity": {
"policy_form_editor_label": "본인인증 정책 추가/편집",
"intro_title": "게시판 본인인증 정책",
"intro": "게시판 모듈이 선언한 정책과 운영자가 추가한 정책을 관리합니다. 모듈 기본 정책은 활성/유예시간 등 일부만 조정 가능하며, 운영자가 추가한 정책은 코어 환경설정에서 자유롭게 편집·삭제할 수 있습니다.",
"purposes_section_title": "이 모듈이 등록한 본인인증 목적",
@@ -1,7 +1,7 @@
{
"form": {
"sections": {
"notification_settings": "알림 설정"
"notification_settings": "게시판 알림 설정"
},
"notifications": {
"post_complete": "게시글 완료 알림",
@@ -13,9 +13,7 @@
"isReadOnly": "{{!!route?.id && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
},
"state": {
"activeTab": "basic",
"showCategoryRemoveModal": false,
"categoryToRemove": null,
"activeTab": "section_basic_card",
"isSaving": false,
"hasChanges": false,
"showCancelConfirmModal": false,
@@ -33,6 +31,7 @@
"data_sources": [
{
"id": "boards",
"label_key": "$t:sirsoft-board.editor.data_source.boards",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards/form-data",
"method": "GET",
@@ -63,6 +62,7 @@
},
{
"id": "roles",
"label_key": "$t:sirsoft-board.editor.data_source.roles",
"type": "api",
"endpoint": "/api/admin/roles/active",
"method": "GET",
@@ -71,13 +71,16 @@
},
{
"id": "availableChannels",
"label_key": "$t:sirsoft-board.editor.data_source.availableChannels",
"type": "api",
"endpoint": "/api/admin/notification-channels",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"fallback": {
"data": { "channels": [] }
"data": {
"channels": []
}
}
}
],
@@ -87,9 +90,11 @@
"id": "board_form_wrapper",
"type": "basic",
"name": "Div",
"blur_until_loaded": true,
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "admin-page-content"
},
"children": [
{
"id": "read_only_banner",
@@ -97,7 +102,7 @@
"name": "Div",
"if": "{{_computed.isReadOnly}}",
"props": {
"className": "mx-6 mt-4 flex items-center gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3"
"className": "alert-warning"
},
"children": [
{
@@ -112,227 +117,92 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
"className": "text-warning-soft"
},
"text": "$t:sirsoft-board.admin.form.read_only_message"
}
]
},
{
"id": "sticky_header_container",
"id": "page_header",
"type": "basic",
"name": "Div",
"props": {
"className": "sticky top-0 z-40 bg-gray-50 dark:bg-gray-900"
"className": "page-header-fullbleed"
},
"children": [
{
"id": "form_header",
"type": "basic",
"name": "Div",
"props": {
"className": "border-b border-gray-200 dark:border-gray-700 py-3 px-6"
},
"children": [
{
"id": "header_flex_container",
"type": "basic",
"name": "Div",
"name": "H1",
"props": {
"className": "flex justify-between items-center"
"className": "page-title"
},
"children": [
{
"id": "header_title",
"type": "basic",
"name": "H1",
"props": {
"className": "text-xl font-semibold text-gray-900 dark:text-white"
},
"text": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
},
{
"id": "header_actions",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3"
},
"children": [
{
"id": "cancel_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"target": "/admin/boards"
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.common.cancel"
}
]
},
{
"id": "save_button",
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"isSaving": true
}
},
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/boards/' + route.id : '/api/modules/sirsoft-board/admin/boards'}}",
"params": {
"method": "{{route?.id ? 'PUT' : 'POST'}}",
"body": "{{_local.form}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false,
"errors": null
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{route?.id ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
}
},
{
"handler": "navigate",
"condition": "{{!route?.id}}",
"params": {
"path": "{{'/admin/boards/' + result.data.id + '/edit'}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}",
"icon": "triangle-exclamation"
}
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{!_local.isSaving}}",
"props": {
"name": "floppy-disk",
"className": "w-4 h-4"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isSaving}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_local.isSaving ? '$t:sirsoft-board.common.saving' : '$t:sirsoft-board.common.save'}}"
}
]
}
]
}
]
"text": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "page-description"
},
"text": "$t:sirsoft-board.admin.form.description"
}
]
},
{
"id": "tab_navigation",
"type": "composite",
"name": "TabNavigationScroll",
"props": {
"activeTabId": "{{_local.activeTab ?? 'basic'}}",
"enableScrollSpy": true,
"scrollSpyOffset": 120,
"tabs": [
{
"id": "basic",
"label": "$t:sirsoft-board.admin.form.tabs.basic"
},
{
"id": "permissions",
"label": "$t:sirsoft-board.admin.form.tabs.permissions"
},
{
"id": "list",
"label": "$t:sirsoft-board.admin.form.tabs.list"
},
{
"id": "post",
"label": "$t:sirsoft-board.admin.form.tabs.post"
},
{
"id": "notification",
"label": "$t:sirsoft-board.admin.form.tabs.notification"
}
]
}
}
]
},
{
"id": "tab_navigation",
"type": "composite",
"name": "TabNavigationScroll",
"props": {
"className": "sticky-tab-nav tab-nav-default",
"activeTabId": "{{_local.activeTab ?? 'section_basic_card'}}",
"enableScrollSpy": true,
"scrollSpyOffset": 56,
"sectionIdPrefix": "",
"tabs": [
{
"id": "section_basic_card",
"label": "$t:sirsoft-board.admin.form.tabs.basic"
},
{
"id": "section_permissions_card",
"label": "$t:sirsoft-board.admin.form.tabs.permissions"
},
{
"id": "section_list_card",
"label": "$t:sirsoft-board.admin.form.tabs.list"
},
{
"id": "section_post_card",
"label": "$t:sirsoft-board.admin.form.tabs.post"
},
{
"id": "section_notification_card",
"label": "$t:sirsoft-board.admin.form.tabs.notification"
}
]
}
},
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "px-6 pt-4"
"className": "pt-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex gap-3"
"className": "alert-danger"
},
"children": [
{
@@ -351,7 +221,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
"className": "text-error-strong"
},
"text": "$t:sirsoft-board.admin.form.validation_error"
},
@@ -360,7 +230,7 @@
"name": "P",
"if": "{{typeof _local.errors === 'string'}}",
"props": {
"className": "mt-2 text-sm text-red-700 dark:text-red-300"
"className": "text-error-soft mt-2"
},
"text": "{{_local.errors}}"
},
@@ -380,7 +250,7 @@
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
"className": "text-error-soft"
},
"text": "{{message}}"
}
@@ -397,24 +267,197 @@
"type": "basic",
"name": "Form",
"props": {
"id": "board_form",
"className": "p-6"
"id": "board_form"
},
"children": [
{
"partial": "partials/admin_board_form/_tab_basic.json"
"id": "board_form_tab_content",
"type": "basic",
"name": "Div",
"props": {
"className": "tab-content"
},
"children": [
{
"partial": "partials/admin_board_form/_tab_basic.json"
},
{
"partial": "partials/admin_board_form/_tab_permissions.json"
},
{
"partial": "partials/admin_board_form/_tab_list.json"
},
{
"partial": "partials/admin_board_form/_tab_post.json"
},
{
"partial": "partials/admin_board_form/_tab_notification.json"
}
]
}
]
},
{
"id": "footer_buttons",
"type": "basic",
"name": "Div",
"props": {
"className": "sticky-footer-buttons-between"
},
"children": [
{
"id": "footer_list_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.list"
}
],
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards"
}
}
]
},
{
"partial": "partials/admin_board_form/_tab_permissions.json"
},
{
"partial": "partials/admin_board_form/_tab_list.json"
},
{
"partial": "partials/admin_board_form/_tab_post.json"
},
{
"partial": "partials/admin_board_form/_tab_notification.json"
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-3"
},
"children": [
{
"id": "footer_cancel_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "btn btn-secondary"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
],
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards"
}
}
]
},
{
"id": "footer_save_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"className": "btn btn-primary flex-center gap-1.5"
},
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isSaving}}",
"props": {
"name": "spinner",
"className": "spinner-sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_local.isSaving ? '$t:sirsoft-board.common.saving' : '$t:sirsoft-board.common.save'}}"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/boards/' + route.id : '/api/modules/sirsoft-board/admin/boards'}}",
"params": {
"method": "{{route?.id ? 'PUT' : 'POST'}}",
"body": "{{_local.form}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false,
"errors": null
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{route?.id ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
}
},
{
"handler": "openWindow",
"condition": "{{!route?.id}}",
"params": {
"path": "{{'/admin/boards/' + result.data.id + '/edit'}}",
"target": "_self"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
}
}
]
}
]
}
]
}
@@ -422,82 +465,5 @@
}
]
},
"modals": [
{
"id": "category_remove_modal",
"type": "composite",
"name": "Modal",
"condition": "{{_local.showCategoryRemoveModal === true}}",
"props": {
"isOpen": "{{_local.showCategoryRemoveModal}}",
"title": "$t:sirsoft-board.admin.form.modals.category_remove.title",
"size": "sm"
},
"actions": [
{
"type": "close",
"handler": "setState",
"target": "_local.showCategoryRemoveModal",
"value": false
}
],
"children": [
{
"id": "category_remove_modal_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"id": "category_remove_modal_message",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.form.modals.category_remove.message"
},
{
"id": "category_remove_modal_warning",
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-board.admin.form.modals.category_remove.warning"
},
{
"id": "category_remove_modal_actions",
"type": "basic",
"name": "Div",
"props": {
"className": "flex mt-6 justify-end"
},
"children": [
{
"id": "category_remove_modal_confirm",
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors"
},
"text": "$t:common.confirm",
"actions": [
{
"type": "click",
"handler": "setState",
"target": "_local.showCategoryRemoveModal",
"value": false
}
]
}
]
}
]
}
]
}
]
"modals": []
}
@@ -17,6 +17,7 @@
"data_sources": [
{
"id": "boards",
"label_key": "$t:sirsoft-board.editor.data_source.boards",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards",
"method": "GET",
@@ -46,7 +47,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900"
"className": "admin-page-content-responsive-fluid"
},
"children": [
{
@@ -63,7 +64,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-800 transition-colors",
"className": "btn-icon",
"aria-label": "$t:common.refresh"
},
"actions": [
@@ -94,13 +95,15 @@
"name": "Button",
"props": {
"disabled": "{{boards?.data?.abilities?.can_create !== true}}",
"className": "flex items-center gap-2 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-2 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"target": "/admin/boards/create"
"params": {
"path": "/admin/boards/create"
}
}
],
"children": [
@@ -126,7 +129,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
"className": "admin-card"
},
"children": [
{
@@ -215,7 +218,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
"className": "section-heading"
},
"text": "{{row.name}}"
},
@@ -224,7 +227,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "({{row.type}})"
}
@@ -387,7 +390,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
"className": "flex-center gap-2 text-label"
},
"children": [
{
@@ -445,7 +448,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.board.index.categories_label"
},
@@ -497,7 +500,7 @@
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.modals.copy.title",
"size": "small"
"size": "sm"
},
"children": [
{
@@ -505,7 +508,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
"className": "row-stack"
},
"children": [
{
@@ -513,18 +516,88 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-900 dark:text-white font-medium"
"className": "text-primary font-medium"
},
"text": "$t:sirsoft-board.admin.modals.copy.message|name={{_global.modal_data.name}}"
},
{
"id": "copy_info",
"id": "copy_info_box",
"type": "basic",
"name": "P",
"name": "Div",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
"className": "rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-4 py-3"
},
"text": "$t:sirsoft-board.admin.modals.copy.info"
"children": [
{
"id": "copy_info_copied",
"type": "basic",
"name": "P",
"props": {
"className": "text-label flex items-start gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-tertiary"
},
"text": "·"
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.modals.copy.info_copied"
}
]
},
{
"id": "copy_info_excluded",
"type": "basic",
"name": "P",
"props": {
"className": "text-label flex items-start gap-2 mt-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-tertiary"
},
"text": "·"
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.modals.copy.info_excluded"
}
]
},
{
"id": "copy_info_slug",
"type": "basic",
"name": "P",
"props": {
"className": "text-label flex items-start gap-2 mt-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-tertiary"
},
"text": "·"
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.modals.copy.info_slug"
}
]
}
]
},
{
"id": "copy_button_group",
@@ -539,7 +612,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:focus:ring-gray-400"
"className": "px-4 py-2 text-body font-medium bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:focus:ring-gray-400"
},
"text": "$t:common.cancel",
"actions": [
@@ -567,7 +640,9 @@
{
"type": "click",
"handler": "navigate",
"target": "/admin/boards/create?copy_id={{_global.modal_data.id}}"
"params": {
"path": "/admin/boards/create?copy_id={{_global.modal_data.id}}"
}
}
]
}
@@ -583,7 +658,7 @@
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.modals.delete.title",
"size": "small"
"size": "sm"
},
"children": [
{
@@ -591,7 +666,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
"className": "row-stack"
},
"children": [
{
@@ -599,7 +674,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-900 dark:text-white font-semibold text-lg"
"className": "text-primary font-semibold text-lg"
},
"text": "$t:sirsoft-board.admin.modals.delete.confirm|name={{_global.modal_data.name}}"
},
@@ -627,7 +702,7 @@
"name": "P",
"if": "{{_global.modal_data.posts_count > 0}}",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
"className": "text-label"
},
"text": "$t:sirsoft-board.admin.modals.delete.backup_notice"
}
@@ -637,7 +712,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "modal-footer-buttons"
},
"children": [
{
@@ -665,7 +740,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.isDeleting}}"
},
"actions": [
@@ -755,7 +830,7 @@
"if": "{{_global.isDeleting}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
},
{
@@ -20,6 +20,7 @@
"data_sources": [
{
"id": "post",
"label_key": "$t:sirsoft-board.editor.data_source.post",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts/{{route.id}}",
"method": "GET",
@@ -28,12 +29,6 @@
"refetchOnMount": true,
"loading_strategy": "progressive",
"errorHandling": {
"404": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
},
"403": {
"handler": "sequence",
"actions": [
@@ -48,6 +43,12 @@
"handler": "navigateBack"
}
]
},
"404": {
"handler": "showErrorPage",
"params": {
"target": "content"
}
}
}
}
@@ -60,12 +61,12 @@
"name": "Div",
"blur_until_loaded": true,
"props": {
"className": "p-6 bg-gray-50 dark:bg-gray-900"
"className": "admin-page-content-fluid"
},
"responsive": {
"portable": {
"props": {
"className": "p-4 bg-gray-50 dark:bg-gray-900"
"className": "admin-page-content-fluid-compact"
}
}
},
@@ -84,7 +85,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded-lg transition-colors"
"className": "btn-icon"
},
"actions": [
{
@@ -112,7 +113,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
"className": "flex-center gap-2 btn btn-secondary"
},
"actions": [
{
@@ -150,11 +151,20 @@
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8"
"className": "admin-card"
},
"children": [
{
"partial": "partials/admin_board_post_detail/_post_card_content.json"
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"partial": "partials/admin_board_post_detail/_post_card_content.json"
}
]
}
]
}
@@ -171,7 +181,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-4"
"className": "flex-center gap-2 mb-4"
},
"children": [
{
@@ -186,7 +196,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
"className": "section-heading"
},
"text": "$t:sirsoft-board.admin.posts.detail.replies_title|count={{post?.data?.reply_count ?? 0}}"
}
@@ -205,11 +215,20 @@
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-4"
"className": "admin-card"
},
"children": [
{
"partial": "partials/admin_board_post_detail/_reply_card_content.json"
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"partial": "partials/admin_board_post_detail/_reply_card_content.json"
}
]
}
]
}
@@ -224,14 +243,15 @@
"modals": [
{
"meta": {
"description": "Universal Delete Modal (Post/Reply/Comment)"
"description": "Universal Delete Modal (Post/Reply/Comment)",
"editor_label": "$t:sirsoft-board.admin.posts.modals.delete_editor_label"
},
"id": "delete_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "{{_global.modal_data?.type === 'comment' ? $t('sirsoft-board.admin.posts.detail.delete_modal.title_comment') : _global.modal_data?.type === 'reply' ? $t('sirsoft-board.admin.posts.detail.delete_modal.title_reply') : $t('sirsoft-board.admin.posts.detail.delete_modal.title_post')}}",
"size": "small"
"size": "sm"
},
"children": [
{
@@ -253,7 +273,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "{{_global.modal_data?.type === 'comment' ? '$t:sirsoft-board.admin.posts.detail.delete_modal.warning_comment' : '$t:sirsoft-board.admin.posts.detail.delete_modal.warning_reply'}}",
"if": "{{_global.modal_data?.type !== 'post'}}"
@@ -264,7 +284,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "modal-footer-buttons"
},
"children": [
{
@@ -382,7 +402,8 @@
},
{
"meta": {
"description": "Universal Report Modal (Post/Comment)"
"description": "Universal Report Modal (Post/Comment)",
"editor_label": "$t:sirsoft-board.admin.posts.modals.report_editor_label"
},
"id": "report_modal",
"type": "composite",
@@ -403,14 +424,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-4"
"className": "alert-warning"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-yellow-800 dark:text-yellow-200"
"className": "text-warning-soft"
},
"text": "$t:sirsoft-board.admin.posts.detail.report_modal.warning_notice"
}
@@ -428,7 +449,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
"className": "text-body"
},
"children": [
{
@@ -459,7 +480,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.posts.detail.report_modal.reason_type_label"
},
@@ -467,7 +488,7 @@
"type": "basic",
"name": "Select",
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
"className": "text-primary w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
},
"actions": [
{
@@ -516,7 +537,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.posts.detail.report_modal.reason_detail_label"
},
@@ -524,7 +545,7 @@
"type": "basic",
"name": "Textarea",
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
"className": "text-primary w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
"rows": 4,
"placeholder": "$t:sirsoft-board.admin.posts.detail.report_modal.reason_placeholder"
},
@@ -547,7 +568,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "modal-footer-buttons"
},
"children": [
{
@@ -678,7 +699,7 @@
"if": "{{_global.reportSubmitting}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
},
{
@@ -694,7 +715,8 @@
},
{
"meta": {
"description": "Blind Modal (Post/Comment)"
"description": "Blind Modal (Post/Comment)",
"editor_label": "$t:sirsoft-board.admin.posts.modals.blind_editor_label"
},
"id": "blind_modal",
"type": "composite",
@@ -723,7 +745,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"children": [
{
@@ -748,7 +770,7 @@
"id": "blind_reason",
"rows": 3,
"placeholder": "$t:sirsoft-board.admin.posts.modals.blind_reason_placeholder",
"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 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
"className": "text-primary w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
},
"actions": [
{
@@ -767,7 +789,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "modal-footer-buttons"
},
"children": [
{
@@ -879,14 +901,15 @@
},
{
"meta": {
"description": "Restore Modal (Post/Comment)"
"description": "Restore Modal (Post/Comment)",
"editor_label": "$t:sirsoft-board.admin.posts.modals.restore_editor_label"
},
"id": "restore_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "{{_global.restore_modal_data?.type === 'comment' ? '$t:sirsoft-board.admin.posts.modals.restore_comment_title' : '$t:sirsoft-board.admin.posts.modals.restore_title'}}",
"size": "small"
"size": "sm"
},
"children": [
{
@@ -910,7 +933,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "modal-footer-buttons"
},
"children": [
{
@@ -29,6 +29,7 @@
"data_sources": [
{
"id": "form_data",
"label_key": "$t:sirsoft-board.editor.data_source.form_data",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts/form-data{{route?.id ? '?post_id=' + route.id : (query?.parent_id ? '?parent_id=' + query.parent_id : '')}}",
"method": "GET",
@@ -62,6 +63,7 @@
},
{
"id": "form_meta",
"label_key": "$t:sirsoft-board.editor.data_source.form_meta",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts/form-meta{{route?.id ? '?post_id=' + route.id : (query?.parent_id ? '?parent_id=' + query.parent_id : '')}}",
"method": "GET",
@@ -113,7 +115,7 @@
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 bg-gray-50 dark:bg-gray-900"
"className": "admin-page-content-responsive-fluid"
},
"children": [
{
@@ -130,7 +132,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
"className": "btn btn-secondary"
},
"actions": [
{
@@ -155,7 +157,7 @@
"name": "Button",
"props": {
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
@@ -246,7 +248,7 @@
"name": "Div",
"if": "{{_computed.isReadOnly}}",
"props": {
"className": "mb-4 flex items-center gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-4 py-3"
"className": "alert-warning"
},
"children": [
{
@@ -261,7 +263,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
"className": "text-warning-soft"
},
"text": "$t:sirsoft-board.admin.posts.form.read_only_message"
}
@@ -273,7 +275,7 @@
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6"
"className": "alert-danger"
},
"children": [
{
@@ -299,7 +301,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
"className": "text-error-strong"
},
"text": "$t:sirsoft-board.admin.form.validation_error"
},
@@ -308,7 +310,7 @@
"name": "P",
"if": "{{typeof _local.errors === 'string'}}",
"props": {
"className": "mt-2 text-sm text-red-700 dark:text-red-300"
"className": "text-error-soft mt-2"
},
"text": "{{_local.errors}}"
},
@@ -328,7 +330,7 @@
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
"className": "text-error-soft"
},
"text": "{{message}}"
}
@@ -353,7 +355,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-6"
"className": "row-stack"
},
"children": [
{
@@ -374,120 +376,162 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 pt-4 mt-6"
"className": "sticky-footer-buttons-between"
},
"children": [
{
"id": "footer_cancel_button",
"type": "basic",
"name": "Button",
"name": "Div",
"if": "{{_local.hasChanges}}",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
"className": "flex-center gap-1.5 text-sm text-amber-600 dark:text-amber-400"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "{{route?.id ? '/admin/board/' + route.slug + '/post/' + route.id : '/admin/board/' + route.slug}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-dot",
"className": "w-3 h-3"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.common.cancel"
"text": "$t:sirsoft-board.admin.settings.unsaved_changes"
}
]
},
{
"id": "footer_save_button",
"type": "basic",
"name": "Button",
"name": "Div",
"if": "{{!_local.hasChanges}}",
"props": {
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": ""
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-3"
},
"actions": [
"children": [
{
"type": "click",
"handler": "sequence",
"id": "footer_cancel_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "btn btn-secondary"
},
"actions": [
{
"handler": "setState",
"type": "click",
"handler": "navigate",
"params": {
"target": "local",
"isSaving": true,
"errors": null
"path": "{{route?.id ? '/admin/board/' + route.slug + '/post/' + route.id : '/admin/board/' + route.slug}}"
}
},
}
],
"children": [
{
"handler": "emitEvent",
"params": {
"event": "upload:board_attachments"
}
},
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.common.cancel"
}
]
},
{
"id": "footer_save_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
"className": "btn btn-primary flex-center gap-1.5"
},
"actions": [
{
"handler": "apiCall",
"auth_required": true,
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/board/' + route.slug + '/posts/' + route.id : '/api/modules/sirsoft-board/admin/board/' + route.slug + '/posts'}}",
"params": {
"method": "{{route?.id ? 'PUT' : 'POST'}}",
"body": "{{Object.assign({}, _local.form, { temp_key: _local.tempKey })}}"
},
"onSuccess": [
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false,
"isSaving": true,
"errors": null
}
},
{
"handler": "toast",
"handler": "emitEvent",
"params": {
"type": "success",
"message": "{{route?.id ? '$t:sirsoft-board.admin.posts.messages.update_success' : '$t:sirsoft-board.admin.posts.messages.create_success'}}"
"event": "upload:board_attachments"
}
},
{
"handler": "navigate",
"handler": "apiCall",
"auth_required": true,
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/board/' + route.slug + '/posts/' + route.id : '/api/modules/sirsoft-board/admin/board/' + route.slug + '/posts'}}",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{route?.id ?? result?.data?.id}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.posts.messages.save_failed",
"icon": "triangle-exclamation"
}
"method": "{{route?.id ? 'PUT' : 'POST'}}",
"body": "{{Object.assign({}, _local.form, { temp_key: _local.tempKey })}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false,
"errors": null
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{route?.id ? '$t:sirsoft-board.admin.posts.messages.update_success' : '$t:sirsoft-board.admin.posts.messages.create_success'}}"
}
},
{
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{route?.id ?? result?.data?.id}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors}}"
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.posts.messages.save_failed",
"icon": "triangle-exclamation"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{_local.isSaving ? '$t:sirsoft-board.common.saving' : '$t:sirsoft-board.common.save'}}"
}
]
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{_local.isSaving ? '$t:sirsoft-board.common.saving' : '$t:sirsoft-board.common.save'}}"
}
]
}
]
@@ -33,6 +33,7 @@
"data_sources": [
{
"id": "posts",
"label_key": "$t:sirsoft-board.editor.data_source.posts",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/board/{{route.slug}}/posts",
"method": "GET",
@@ -84,12 +85,12 @@
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 bg-gray-50 dark:bg-gray-900"
"className": "admin-page-content-fluid"
},
"responsive": {
"portable": {
"props": {
"className": "p-4 bg-gray-50 dark:bg-gray-900"
"className": "admin-page-content-fluid-compact"
}
}
},
@@ -108,7 +109,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -116,7 +117,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded-lg transition-colors"
"className": "btn-icon"
},
"actions": [
{
@@ -145,7 +146,7 @@
"name": "Button",
"props": {
"disabled": "{{posts?.data?.abilities?.can_write !== true}}",
"className": "flex items-center gap-2 px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-2 px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
@@ -181,7 +182,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4"
"className": "admin-card"
},
"children": [
{
@@ -189,7 +190,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-row gap-3 items-center"
"className": "flex-center flex-row gap-3"
},
"children": [
{
@@ -198,7 +199,7 @@
"name": "Select",
"props": {
"value": "{{_local.searchField || 'all'}}",
"className": "w-32 px-4 py-2.5 bg-gray-100 dark:bg-gray-700 border-0 rounded-xl text-gray-700 dark:text-gray-200 font-medium focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
"className": "select-composite w-32",
"options": [
{
"label": "$t:sirsoft-board.admin.posts.search_fields.all",
@@ -263,7 +264,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2.5 bg-gray-800 dark:bg-gray-700 border-0 rounded-xl text-white dark:text-gray-200 font-medium hover:bg-gray-900 dark:hover:bg-gray-600 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors text-sm"
"className": "flex-center gap-2 px-4 py-2.5 bg-gray-800 dark:bg-gray-700 border-0 rounded-xl text-white dark:text-gray-200 font-medium hover:bg-gray-900 dark:hover:bg-gray-600 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-colors text-sm"
},
"actions": [
{
@@ -307,7 +308,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-3"
"className": "flex-center flex-wrap gap-3"
},
"if": "{{posts?.data?.board?.categories && posts?.data?.board?.categories.length > 0}}",
"children": [
@@ -315,7 +316,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[80px]"
"className": "text-body font-medium min-w-[80px]"
},
"text": "$t:sirsoft-board.admin.posts.filters.category_label"
},
@@ -325,7 +326,7 @@
"name": "Select",
"props": {
"value": "{{query.category || 'all'}}",
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm min-w-[200px]",
"className": "input min-w-[200px]",
"options": "{{[{value:'all', label:'$t:sirsoft-board.admin.posts.filters.category_all'},{value:'unclassified', label:'$t:sirsoft-board.admin.posts.filters.category_unclassified'}].concat((posts?.data?.board?.categories||[]).map(function(c){return{value:c,label:c};}))}}"
},
"actions": [
@@ -351,14 +352,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-3"
"className": "flex-center flex-wrap gap-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[80px]"
"className": "text-body font-medium min-w-[80px]"
},
"text": "$t:sirsoft-board.admin.posts.filters.date_from"
},
@@ -369,7 +370,7 @@
"props": {
"type": "date",
"value": "{{query.created_at_from || ''}}",
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm min-w-[200px]"
"className": "input min-w-[200px]"
},
"actions": [
{
@@ -386,7 +387,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "~"
},
@@ -394,7 +395,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[80px]"
"className": "text-body font-medium min-w-[80px]"
},
"text": "$t:sirsoft-board.admin.posts.filters.date_to"
},
@@ -405,7 +406,7 @@
"props": {
"type": "date",
"value": "{{query.created_at_to || ''}}",
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm min-w-[200px]"
"className": "input min-w-[200px]"
},
"actions": [
{
@@ -425,14 +426,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-3"
"className": "flex-center flex-wrap gap-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[80px]"
"className": "text-body font-medium min-w-[80px]"
},
"text": "$t:sirsoft-board.admin.posts.filters.status_label"
},
@@ -505,12 +506,12 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-sm font-medium"
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-body border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium"
},
"responsive": {
"portable": {
"props": {
"className": "w-full 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 transition-colors text-sm font-medium"
"className": "w-full px-4 py-2 bg-white dark:bg-gray-800 text-body border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium"
}
}
},
@@ -592,7 +593,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end items-center gap-3 mb-4"
"className": "flex-center justify-end gap-3 mb-4"
},
"children": [
{
@@ -761,7 +762,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-wrap"
"className": "flex-center gap-2 flex-wrap"
},
"children": [
{
@@ -869,14 +870,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-1"
"className": "row-stack"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -921,7 +922,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "text-tertiary"
},
"text": "({{row.author.email}})",
"if": "{{!row.author.is_guest && row.author.email}}"
@@ -988,7 +989,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "mt-4 text-sm text-gray-600 dark:text-gray-400 text-center"
"className": "mt-4 text-label text-center"
},
"children": [
{
@@ -1009,7 +1010,7 @@
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.posts.modals.delete_title",
"size": "small"
"size": "sm"
},
"children": [
{
@@ -1033,7 +1034,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "modal-footer-buttons"
},
"children": [
{
@@ -29,6 +29,7 @@
"data_sources": [
{
"id": "report_detail",
"label_key": "$t:sirsoft-board.editor.data_source.report_detail",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/reports/{{route.id}}",
"method": "GET",
@@ -52,6 +53,7 @@
},
{
"id": "reporters_list",
"label_key": "$t:sirsoft-board.editor.data_source.reporters_list",
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/reports/{{route.id}}/reporters",
"method": "GET",
@@ -71,12 +73,12 @@
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 bg-gray-50 dark:bg-gray-900"
"className": "admin-page-content-fluid"
},
"responsive": {
"portable": {
"props": {
"className": "p-4 bg-gray-50 dark:bg-gray-900"
"className": "admin-page-content-fluid-compact"
}
}
},
@@ -95,7 +97,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded-lg transition-colors"
"className": "btn-icon"
},
"actions": [
{
@@ -123,7 +125,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors"
"className": "flex-center gap-2 btn btn-secondary"
},
"actions": [
{
@@ -167,6 +169,9 @@
},
"modals": [
{
"meta": {
"editor_label": "$t:sirsoft-board.admin.reports.modals.status_action_editor_label"
},
"id": "status_action_modal",
"type": "composite",
"name": "Modal",
@@ -195,7 +200,7 @@
"name": "Div",
"if": "{{_global.actionType === 'rejected' && report_detail.data?.target_status === 'blinded'}}",
"props": {
"className": "mb-4 flex items-center gap-2 p-2.5 bg-orange-50 dark:bg-orange-900/20 rounded-md"
"className": "mb-4 flex-center gap-2 p-2.5 bg-orange-50 dark:bg-orange-900/20 rounded-md"
},
"children": [
{
@@ -220,7 +225,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.reports.modals.reason_label"
},
@@ -228,7 +233,7 @@
"type": "basic",
"name": "Textarea",
"props": {
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white",
"className": "text-primary w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800",
"rows": 4,
"placeholder": "$t:sirsoft-board.admin.reports.modals.reason_placeholder",
"value": "{{_global.actionReason || ''}}"
@@ -250,7 +255,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "modal-footer-buttons"
},
"children": [
{
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -16,9 +16,6 @@
"id": "bt_modal_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"classMap": {
"key": "{{$parent._computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
@@ -32,7 +29,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg px-3 py-2.5"
"className": "alert-info text-xs text-blue-600 dark:text-blue-400"
},
"children": [
{
@@ -68,7 +65,7 @@
"type": "basic",
"name": "Table",
"props": {
"className": "min-w-full divide-y divide-gray-200 dark:divide-gray-700"
"className": "divider-list min-w-full"
},
"children": [
{
@@ -86,7 +83,7 @@
"type": "basic",
"name": "Th",
"props": {
"className": "px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
"className": "text-table-header px-3 py-2 text-left"
},
"text": "$t:sirsoft-board.admin.board_types.slug"
},
@@ -95,7 +92,7 @@
"type": "basic",
"name": "Th",
"props": {
"className": "px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
"className": "text-table-header px-3 py-2 text-left"
},
"text": "$t:sirsoft-board.admin.board_types.name"
},
@@ -104,7 +101,7 @@
"type": "basic",
"name": "Th",
"props": {
"className": "px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24"
"className": "text-table-header px-3 py-2 text-center w-24"
},
"text": "$t:sirsoft-board.admin.board_types.actions"
}
@@ -117,7 +114,7 @@
"type": "basic",
"name": "Tbody",
"props": {
"className": "bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"
"className": "divider-list bg-white dark:bg-gray-800"
},
"children": [
{
@@ -132,7 +129,7 @@
"name": "Td",
"props": {
"colSpan": 3,
"className": "px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
"className": "px-3 py-8 text-center text-label-subtle"
},
"text": "$t:sirsoft-board.admin.board_types.empty"
}
@@ -190,7 +187,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-center gap-1"
"className": "flex-center justify-center gap-1"
},
"children": [
{
@@ -342,7 +339,7 @@
"if": "{{_global.btDeletingId === btItem.id}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
}
],
@@ -365,7 +362,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "border-t border-gray-200 dark:border-gray-700 pt-4 rounded-lg transition-all duration-200"
"className": "border-t border-gray-200 dark:border-gray-700 rounded-lg transition-all duration-200"
},
"classMap": {
"key": "{{_global.btFormMode}}",
@@ -380,7 +377,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-3"
"className": "flex-between mb-3"
},
"children": [
{
@@ -388,7 +385,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
"className": "text-primary text-sm font-semibold"
},
"text": "{{_global.btFormMode === 'edit' ? '$t:sirsoft-board.admin.board_types.edit_title' : '$t:sirsoft-board.admin.board_types.add_title'}}"
},
@@ -426,9 +423,6 @@
"id": "bt_form_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"id": "bt_field_slug",
@@ -452,14 +446,14 @@
"type": "text",
"value": "{{_global.btFormSlug ?? ''}}",
"placeholder": "basic",
"className": "w-full px-2 py-1.5 text-sm border rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500",
"className": "input font-mono",
"disabled": "{{_computed.isReadOnly || _global.btFormMode === 'edit'}}"
},
"classMap": {
"key": "{{_global.btFormErrors?.slug ? 'error' : 'normal'}}",
"variants": {
"error": "border-red-500 dark:border-red-400",
"normal": "border-gray-300 dark:border-gray-600"
"error": "input-error",
"normal": ""
}
},
"actions": [
@@ -480,7 +474,7 @@
"name": "P",
"if": "{{_global.btFormMode !== 'edit' && !_global.btFormErrors?.slug}}",
"props": {
"className": "mt-1 text-xs text-gray-400 dark:text-gray-500"
"className": "text-hint mt-1"
},
"text": "$t:sirsoft-board.admin.board_types.slug_hint"
},
@@ -490,7 +484,7 @@
"name": "P",
"if": "{{_global.btFormErrors?.slug}}",
"props": {
"className": "mt-1 text-xs text-red-500 dark:text-red-400"
"className": "form-error-xs mt-1"
},
"text": "{{_global.btFormErrors?.slug?.[0] ?? ''}}"
}
@@ -542,7 +536,7 @@
"name": "P",
"if": "{{_global.btFormErrors?.['name.ko'] || _global.btFormErrors?.['name']}}",
"props": {
"className": "mt-1 text-xs text-red-500 dark:text-red-400"
"className": "form-error-xs mt-1"
},
"text": "{{_global.btFormErrors?.['name.ko']?.[0] ?? _global.btFormErrors?.['name']?.[0] ?? ''}}"
}
@@ -564,7 +558,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_computed.isReadOnly || _global.btSaving || !_global.btFormSlug || !_global.btFormName?.ko}}"
},
"actions": [
@@ -673,7 +667,7 @@
"if": "{{_global.btSaving}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
},
{
@@ -3,11 +3,11 @@
"is_partial": true,
"description": "Board Form - Basic Tab Content"
},
"id": "tab_content_basic",
"id": "section_basic_card",
"type": "basic",
"name": "Div",
"props": {
"id": "tab_content_basic"
"className": "admin-card"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -17,34 +17,29 @@
}
},
"children": [
{
"id": "section_basic_card",
"type": "layout",
"name": "SectionLayout",
"props": {
"background": "white",
"border": true,
"shadow": "md",
"rounded": true,
"padding": "xl",
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"id": "section_basic_settings",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-6 border-b-2 border-gray-200 dark:border-gray-700"
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.form.tabs.basic"
},
{
"id": "section_basic_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"id": "field_is_active",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-between items-start mb-6"
"className": "flex-start justify-between"
},
"children": [
{
@@ -58,7 +53,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"text": "$t:sirsoft-board.admin.form.fields.is_active"
},
@@ -66,7 +61,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.descriptions.is_active"
}
@@ -88,7 +83,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6"
"className": "space-y-2"
},
"children": [
{
@@ -97,7 +92,7 @@
"name": "Label",
"props": {
"required": true,
"className": "block text-sm font-medium text-gray-900 dark:text-white mb-1"
"className": "block text-heading mb-1"
},
"children": [
{
@@ -109,7 +104,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-red-500 dark:text-red-400 ml-1"
"className": "text-required"
},
"text": "*"
}
@@ -125,7 +120,7 @@
"placeholder": "$t:sirsoft-board.admin.form.placeholders.slug",
"required": true,
"disabled": "{{!!route?.id || _computed.isReadOnly}}",
"className": "w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 {{!!route?.id || _computed.isReadOnly ? 'border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed' : 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}"
"className": "font-mono"
}
},
{
@@ -133,7 +128,7 @@
"name": "Span",
"if": "{{_local.errors?.slug}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
"className": "form-error-xs mt-1"
},
"text": "{{_local.errors?.slug?.[0] ?? ''}}"
},
@@ -142,7 +137,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.descriptions.slug"
},
@@ -158,7 +153,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 mb-3"
"className": "flex-start gap-2 mb-3"
},
"children": [
{
@@ -166,14 +161,14 @@
"name": "Icon",
"props": {
"name": "circle-info",
"className": "w-4 h-4 text-gray-600 dark:text-gray-400 mt-0.5 flex-shrink-0"
"className": "text-secondary w-4 h-4 mt-0.5 flex-shrink-0"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-300"
"className": "text-body"
},
"text": "$t:sirsoft-board.admin.form.menu.manual_guide"
}
@@ -183,7 +178,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 text-xs text-gray-600 dark:text-gray-400"
"className": "text-secondary space-y-2 text-xs"
},
"children": [
{
@@ -205,7 +200,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 flex-1"
"className": "flex-center gap-2 flex-1"
},
"children": [
{
@@ -229,66 +224,6 @@
"text": "/admin/board/{{_local.form?.slug ?? 'notice'}}"
}
]
},
{
"type": "basic",
"name": "Button",
"if": "{{!!route?.id}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-blue-500 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_computed.isReadOnly}}"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "plus",
"className": "w-3 h-3"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.form.menu.add_to_admin_menu"
}
],
"actions": [
{
"type": "click",
"handler": "apiCall",
"target": "/api/modules/sirsoft-board/admin/boards/{{route?.id}}/add-to-menu",
"params": {
"method": "POST"
},
"auth_required": true,
"onSuccess": [
{
"handler": "toast",
"params": {
"type": "success",
"message": "{{result.message}}"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "admin_menu",
"sync": true
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
}
]
}
@@ -334,6 +269,49 @@
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-3 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-heading"
},
"text": "$t:sirsoft-board.admin.form.menu.show_in_admin_menu"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-secondary text-xs mt-0.5"
},
"text": "$t:sirsoft-board.admin.form.menu.show_in_admin_menu_desc"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "add_to_menu",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0"
}
}
]
}
]
}
@@ -344,7 +322,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6"
"className": "space-y-2"
},
"children": [
{
@@ -353,7 +331,7 @@
"name": "Label",
"props": {
"required": true,
"className": "block text-sm font-medium text-gray-900 dark:text-white mb-1"
"className": "block text-heading mb-1"
},
"children": [
{
@@ -365,7 +343,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-red-500 dark:text-red-400 ml-1"
"className": "text-required"
},
"text": "*"
}
@@ -389,7 +367,7 @@
"name": "Span",
"if": "{{_local.errors?.name}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
"className": "form-error-xs mt-1"
},
"text": "{{_local.errors?.name?.[0] ?? ''}}"
}
@@ -400,7 +378,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6"
"className": "space-y-2"
},
"children": [
{
@@ -408,7 +386,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-900 dark:text-white mb-1"
"className": "block text-heading mb-1"
},
"children": [
{
@@ -436,7 +414,7 @@
"name": "Span",
"if": "{{_local.errors?.description}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
"className": "form-error-xs mt-1"
},
"text": "{{_local.errors?.description?.[0] ?? ''}}"
},
@@ -444,7 +422,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.descriptions.description"
}
@@ -455,7 +433,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6"
"className": "space-y-2"
},
"children": [
{
@@ -464,7 +442,7 @@
"name": "Label",
"props": {
"required": true,
"className": "block text-sm font-medium text-gray-900 dark:text-white mb-1"
"className": "block text-heading mb-1"
},
"children": [
{
@@ -490,7 +468,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.descriptions.type"
}
@@ -501,7 +479,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-6 pb-6"
"className": "space-y-2"
},
"children": [
{
@@ -509,7 +487,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"children": [
{
@@ -524,7 +502,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-2"
"className": "text-tertiary mb-2"
},
"text": "$t:sirsoft-board.admin.form.descriptions.categories"
},
@@ -537,25 +515,14 @@
"options": "{{_local.form?.category_options ?? []}}",
"creatable": true,
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
{
"type": "before_remove",
"handler": "check_category_usage",
"on_blocked": {
"handler": "setState",
"target": "_local.showCategoryRemoveModal",
"value": true
}
}
]
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.categories}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
"className": "form-error-xs mt-1"
},
"text": "{{_local.errors?.categories?.[0] ?? ''}}"
},
@@ -575,7 +542,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex pt-4 justify-between items-center"
"className": "flex-between"
},
"children": [
{
@@ -583,7 +550,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"children": [
{
@@ -624,7 +591,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex pt-4 justify-between items-start"
"className": "flex-start justify-between"
},
"children": [
{
@@ -638,7 +605,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"text": "$t:sirsoft-board.admin.form.fields.show_view_count"
},
@@ -646,7 +613,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.descriptions.show_view_count"
}
@@ -668,7 +635,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex pt-4 justify-between items-start"
"className": "flex-start justify-between"
},
"children": [
{
@@ -682,7 +649,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"text": "$t:sirsoft-board.admin.form.fields.use_report"
},
@@ -690,7 +657,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.descriptions.use_report"
}
@@ -707,7 +674,7 @@
}
]
}
]
}
]
}
]
}
@@ -3,11 +3,11 @@
"is_partial": true,
"description": "Board Form - List Tab Content"
},
"id": "tab_content_list",
"id": "section_list_card",
"type": "basic",
"name": "Div",
"props": {
"id": "tab_content_list"
"className": "admin-card"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -17,34 +17,29 @@
}
},
"children": [
{
"id": "section_list_card",
"type": "layout",
"name": "SectionLayout",
"props": {
"background": "white",
"border": true,
"shadow": "md",
"rounded": true,
"padding": "xl",
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"id": "section_list",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-6 border-b-2 border-gray-200 dark:border-gray-700"
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.form.tabs.list"
},
{
"id": "section_list_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"id": "list_sort_group",
"type": "basic",
"name": "Div",
"props": {
"className": "grid mb-6 pb-8 grid-cols-2 gap-4"
"className": "grid-2col"
},
"children": [
{
@@ -60,7 +55,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"children": [
{
@@ -113,7 +108,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"children": [
{
@@ -152,7 +147,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-2 gap-4"
"className": "grid-2col"
},
"children": [
{
@@ -168,7 +163,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"children": [
{
@@ -187,7 +182,7 @@
"name": "per_page",
"required": true,
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
"className": ""
}
},
{
@@ -195,7 +190,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.fields.per_page.hint|min={{board_config?.data?.limits?.per_page_min ?? 5}}|max={{board_config?.data?.limits?.per_page_max ?? 100}}"
},
@@ -204,7 +199,7 @@
"name": "Span",
"if": "{{_local.errors?.per_page}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
"className": "form-error-xs mt-1"
},
"text": "{{_local.errors?.per_page?.[0] ?? ''}}"
}
@@ -223,7 +218,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"children": [
{
@@ -242,7 +237,7 @@
"name": "per_page_mobile",
"required": true,
"disabled": "{{_computed.isReadOnly}}",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
"className": ""
}
},
{
@@ -250,7 +245,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-xs text-gray-500 dark:text-gray-400"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.fields.per_page_mobile.hint|min={{board_config?.data?.limits?.per_page_min ?? 5}}|max={{board_config?.data?.limits?.per_page_max ?? 100}}"
},
@@ -259,7 +254,7 @@
"name": "Span",
"if": "{{_local.errors?.per_page_mobile}}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1"
"className": "form-error-xs mt-1"
},
"text": "{{_local.errors?.per_page_mobile?.[0] ?? ''}}"
}
@@ -267,7 +262,7 @@
}
]
}
]
}
]
}
]
}
}
@@ -3,11 +3,11 @@
"is_partial": true,
"description": "Board Form - Notification Tab Content"
},
"id": "tab_content_notification",
"id": "section_notification_card",
"type": "basic",
"name": "Div",
"props": {
"id": "tab_content_notification"
"className": "admin-card"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -18,139 +18,107 @@
},
"children": [
{
"id": "section_notification_card",
"type": "layout",
"name": "SectionLayout",
"id": "section_notification",
"type": "basic",
"name": "H3",
"props": {
"background": "white",
"border": true,
"shadow": "md",
"rounded": true,
"padding": "xl",
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.form.tabs.notification"
},
{
"id": "section_notification_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"id": "section_notification",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 border-b-2 border-gray-200 dark:border-gray-700"
},
"text": "$t:sirsoft-board.admin.form.tabs.notification"
},
{
"id": "notification_settings_section",
"id": "notify_author_toggle",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-0"
"className": "flex-start justify-between"
},
"children": [
{
"comment": "작성자 댓글 알림",
"id": "notify_author_section",
"type": "basic",
"name": "Div",
"props": {
"className": "border-b border-gray-200 dark:border-gray-700"
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"name": "Label",
"props": {
"className": "flex justify-between items-start py-4"
"className": "text-heading"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_author.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_author.description"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "notify_author",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
"text": "$t:sirsoft-board.admin.form.fields.notify_author.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_author.description"
}
]
},
{
"comment": "신규 게시글 관리자 알림",
"id": "notify_admin_on_post_section",
"type": "composite",
"name": "Toggle",
"props": {
"name": "notify_author",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
},
{
"id": "notify_admin_on_post_toggle",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-start justify-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Div",
"name": "Label",
"props": {
"className": "flex justify-between items-start py-4"
"className": "text-heading"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_admin_on_post.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_admin_on_post.description"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "notify_admin_on_post",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
"text": "$t:sirsoft-board.admin.form.fields.notify_admin_on_post.label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.fields.notify_admin_on_post.description"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "notify_admin_on_post",
"disabled": "{{_computed.isReadOnly}}",
"className": "flex-shrink-0 ml-4"
}
}
]
}
@@ -3,11 +3,11 @@
"is_partial": true,
"description": "Board Form - Permissions Tab Content"
},
"id": "tab_content_permissions",
"id": "section_permissions_card",
"type": "basic",
"name": "Div",
"props": {
"id": "tab_content_permissions"
"className": "admin-card"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -17,34 +17,29 @@
}
},
"children": [
{
"id": "section_permissions_card",
"type": "layout",
"name": "SectionLayout",
"props": {
"background": "white",
"border": true,
"shadow": "md",
"rounded": true,
"padding": "xl",
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
},
"children": [
{
"id": "section_permissions_header",
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-6 border-b-2 border-gray-200 dark:border-gray-700"
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.form.tabs.permissions"
},
{
"id": "section_permissions_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"id": "permissions_section_managers",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-8"
"className": ""
},
"children": [
{
@@ -52,7 +47,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"children": [
{
@@ -67,7 +62,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
"className": "text-tertiary mb-3"
},
"text": "$t:sirsoft-board.admin.form.permissions.managers_description"
},
@@ -138,7 +133,7 @@
"name": "Span",
"if": "{{ !!_local.errors?.board_manager_ids }}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
"className": "form-error"
},
"text": "{{ _local.errors?.board_manager_ids?.[0] }}"
}
@@ -149,7 +144,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2 mb-8"
"className": ""
},
"children": [
{
@@ -157,7 +152,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"children": [
{
@@ -172,7 +167,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
"className": "text-tertiary mb-3"
},
"text": "$t:sirsoft-board.admin.form.permissions.steps_description"
},
@@ -243,7 +238,7 @@
"name": "Span",
"if": "{{ !!_local.errors?.board_step_ids }}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
"className": "form-error"
},
"text": "{{ _local.errors?.board_step_ids?.[0] }}"
}
@@ -254,7 +249,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "pt-6"
"className": ""
},
"children": [
{
@@ -268,7 +263,7 @@
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"text": "$t:sirsoft-board.admin.form.permissions.settings_title"
},
@@ -276,7 +271,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
"className": "form-hint"
},
"text": "$t:sirsoft-board.admin.form.permissions.settings_description"
},
@@ -285,7 +280,7 @@
"name": "P",
"if": "{{ !!route?.id }}",
"props": {
"className": "text-xs text-blue-600 dark:text-blue-400 mt-2 flex items-center gap-1"
"className": "flex-center text-xs text-blue-600 dark:text-blue-400 mt-2 gap-1"
},
"children": [
{
@@ -312,7 +307,7 @@
"name": "Div",
"if": "{{ !route?.id }}",
"props": {
"className": "mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg flex items-center gap-3"
"className": "alert-info"
},
"children": [
{
@@ -328,7 +323,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-blue-700 dark:text-blue-300"
"className": "text-info-soft"
},
"text": "$t:sirsoft-board.admin.form.permissions.create_mode_permissions_notice"
}
@@ -340,14 +335,14 @@
"name": "Div",
"if": "{{ !!route?.id && (_local.errors?.permissions || Object.keys(_local.errors ?? {}).some(k => k.startsWith('permissions.'))) }}",
"props": {
"className": "mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
"className": "alert-danger"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
"className": "flex-start gap-3"
},
"children": [
{
@@ -367,7 +362,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
"className": "text-error-strong"
},
"text": "$t:sirsoft-board.admin.form.permissions.error_title"
},
@@ -375,7 +370,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-red-700 dark:text-red-300 mt-1"
"className": "text-error-soft mt-1"
},
"text": "{{ _local.errors?.permissions?.[0] ?? '$t:sirsoft-board.admin.form.permissions.error_description' }}"
}
@@ -407,7 +402,7 @@
"name": "Div",
"props": {
"slot": "trigger",
"className": "flex items-center gap-2 w-full px-2 py-1"
"className": "flex-center gap-2 w-full px-2 py-1"
},
"children": [
{
@@ -423,7 +418,7 @@
"type": "basic",
"name": "H4",
"props": {
"className": "font-medium text-gray-900 dark:text-white"
"className": "text-primary font-medium"
},
"text": "$t:sirsoft-board.admin.form.permissions.admin_permissions_title"
},
@@ -450,7 +445,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
"className": "text-tertiary mb-3"
},
"text": "$t:sirsoft-board.admin.form.permissions.admin_permissions_description"
},
@@ -459,7 +454,7 @@
"name": "Div",
"if": "{{_local.form?.permissions !== undefined}}",
"props": {
"className": "space-y-2 grid grid-cols-1 md:grid-cols-2 md:gap-2"
"className": "space-y-2 grid-2col-responsive"
},
"children": [
{
@@ -490,7 +485,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.name}}"
},
@@ -498,7 +493,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5"
"className": "text-tertiary mt-0.5"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.name}}_desc"
}
@@ -535,7 +530,7 @@
"name": "Span",
"if": "{{ _local.errors?.['permissions.' + perm?._key + '.roles'] }}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
"className": "form-error"
},
"text": "{{ _local.errors?.['permissions.' + perm?._key + '.roles']?.[0] ?? '$t:sirsoft-board.admin.form.permissions.error_roles_required' }}"
}
@@ -573,7 +568,7 @@
"name": "Div",
"props": {
"slot": "trigger",
"className": "flex items-center gap-2 w-full px-2 py-1"
"className": "flex-center gap-2 w-full px-2 py-1"
},
"children": [
{
@@ -589,7 +584,7 @@
"type": "basic",
"name": "H4",
"props": {
"className": "font-medium text-gray-900 dark:text-white"
"className": "text-primary font-medium"
},
"text": "$t:sirsoft-board.admin.form.permissions.user_permissions_title"
},
@@ -616,7 +611,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mb-3"
"className": "text-tertiary mb-3"
},
"text": "$t:sirsoft-board.admin.form.permissions.user_permissions_description"
},
@@ -625,7 +620,7 @@
"name": "Div",
"if": "{{_local.form?.permissions !== undefined}}",
"props": {
"className": "space-y-2 grid grid-cols-1 md:grid-cols-2 md:gap-2"
"className": "space-y-2 grid-2col-responsive"
},
"children": [
{
@@ -656,7 +651,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.name}}"
},
@@ -664,7 +659,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5"
"className": "text-tertiary mt-0.5"
},
"text": "$t:sirsoft-board.board.permissions.{{perm?.name}}_desc"
}
@@ -701,7 +696,7 @@
"name": "Span",
"if": "{{ _local.errors?.['permissions.' + perm?._key + '.roles'] }}",
"props": {
"className": "text-red-500 dark:text-red-400 text-xs mt-1 block"
"className": "form-error"
},
"text": "{{ _local.errors?.['permissions.' + perm?._key + '.roles']?.[0] ?? '$t:sirsoft-board.admin.form.permissions.error_roles_required' }}"
}
@@ -719,7 +714,7 @@
}
]
}
]
}
]
}
]
}
}
@@ -11,7 +11,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start justify-between gap-1"
"className": "flex-start justify-between gap-1"
},
"children": [
{
@@ -19,7 +19,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 flex-1 min-w-0"
"className": "flex-start gap-2 flex-1 min-w-0"
},
"children": [
{
@@ -36,14 +36,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col gap-1 flex-1 min-w-0"
"className": "flex-1 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -86,7 +86,7 @@
"name": "Span",
"if": "{{comment?.author?.is_guest}}",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
"className": "text-primary text-sm font-semibold"
},
"text": "{{comment?.author?.name}}"
},
@@ -94,7 +94,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "text-tertiary"
},
"text": "({{comment?.author?.email}})",
"if": "{{comment?.author?.email}}"
@@ -121,7 +121,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "text-tertiary"
},
"text": "· {{comment?.created_at}}"
},
@@ -129,7 +129,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "text-tertiary"
},
"if": "{{comment?.ip_address}}",
"text": "· IP: {{comment?.ip_address}}"
@@ -145,7 +145,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center flex-shrink-0"
"className": "flex-center gap-2 flex-shrink-0"
},
"children": [
{
@@ -154,7 +154,7 @@
"name": "Button",
"if": "{{post?.data?.board?.use_reply && (comment?.depth ?? 0) < (post?.data?.board?.max_comment_depth ?? 10) && comment?.status !== 'blinded' && comment?.status !== 'deleted' && !comment?.deleted_at && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"className": "text-tertiary inline-flex items-center gap-1 px-2 py-1 font-medium hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{post?.data?.abilities?.can_write_comments !== true}}"
},
"actions": [
@@ -193,7 +193,7 @@
"name": "Button",
"if": "{{post?.data?.board?.use_report && comment?.author?.uuid !== current_user?.data?.uuid && comment?.status !== 'blinded' && comment?.status !== 'deleted' && !comment?.deleted_at && !comment?.is_already_reported}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded transition-colors"
"className": "text-tertiary inline-flex items-center gap-1 px-2 py-1 font-medium hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 rounded transition-colors"
},
"actions": [
{
@@ -241,7 +241,7 @@
"if": "{{post?.data?.board?.use_report && comment?.author?.uuid !== current_user?.data?.uuid && comment?.status !== 'blinded' && comment?.status !== 'deleted' && !comment?.deleted_at && comment?.is_already_reported}}",
"props": {
"disabled": true,
"className": "inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-400 dark:text-gray-500 rounded cursor-not-allowed opacity-60"
"className": "text-hint inline-flex items-center gap-1 px-2 py-1 font-medium rounded cursor-not-allowed opacity-60"
},
"children": [
{
@@ -364,7 +364,7 @@
"id": "deleted_comment_content",
"type": "basic",
"name": "Div",
"if": "{{comment?.deleted_at}}",
"if": "{{comment?.deleted_at && !comment?.is_cascade_deleted}}",
"props": {
"className": "mt-1.5 px-4"
},
@@ -373,14 +373,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center flex-wrap gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"
"className": "flex-center flex-wrap gap-x-4 gap-y-2 text-label-subtle"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -402,7 +402,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"className": "text-secondary px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{comment?.abilities?.can_manage !== true}}"
},
"actions": [
@@ -424,7 +424,7 @@
"name": "Div",
"if": "{{_local.showDeletedCommentId === comment?.id}}",
"props": {
"className": "mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-gray-600 dark:text-gray-400 whitespace-pre-line leading-relaxed"
"className": "mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-label whitespace-pre-line leading-relaxed"
},
"text": "{{comment.content}}"
}
@@ -443,14 +443,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center flex-wrap gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"
"className": "flex-center flex-wrap gap-x-4 gap-y-2 text-label-subtle"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center flex-wrap gap-2"
"className": "flex-center flex-wrap gap-2"
},
"children": [
{
@@ -481,7 +481,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"className": "text-secondary px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{comment?.abilities?.can_manage !== true}}"
},
"actions": [
@@ -503,7 +503,7 @@
"name": "Div",
"if": "{{_local.showBlindedCommentId === comment?.id}}",
"props": {
"className": "mt-2 p-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm text-gray-600 dark:text-gray-400 whitespace-pre-line leading-relaxed"
"className": "mt-2 p-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-label whitespace-pre-line leading-relaxed"
},
"text": "{{comment.content}}"
}
@@ -513,9 +513,9 @@
"id": "normal_comment_content",
"type": "basic",
"name": "Div",
"if": "{{comment?.status !== 'blinded' && !comment?.deleted_at}}",
"if": "{{comment?.status !== 'blinded' && (!comment?.deleted_at || comment?.is_cascade_deleted)}}",
"props": {
"className": "mt-1.5 px-4 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line leading-relaxed"
"className": "mt-1.5 px-4 text-body whitespace-pre-line leading-relaxed"
},
"text": "{{comment.content}}"
}
@@ -536,7 +536,7 @@
"props": {
"value": "{{_local.edit_comment_content ?? comment.content}}",
"rows": 3,
"className": "w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white mb-2"
"className": "text-primary w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 mb-2"
},
"actions": [
{
@@ -560,7 +560,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
"className": "px-3 py-1.5 text-body bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
@@ -660,7 +660,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -668,7 +668,7 @@
"name": "Icon",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
},
"if": "{{_local.editSubmitting}}"
},
@@ -729,4 +729,4 @@
]
}
]
}
}
@@ -16,7 +16,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 px-6 pb-3 border-b border-gray-200 dark:border-gray-700 pb-2"
"className": "flex-center gap-2 px-6 pb-3 border-b border-gray-200 dark:border-gray-700 pb-2"
},
"children": [
{
@@ -31,7 +31,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white"
"className": "section-heading"
},
"text": "$t:sirsoft-board.admin.posts.detail.comments_title|count={{post?.data?.comment_count ?? 0}}"
}
@@ -84,7 +84,7 @@
"name": "Div",
"if": "{{_local.replyingTo === comment.id && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at}}",
"props": {
"className": "px-4 py-3 border-b border-gray-200 dark:border-gray-700",
"className": "px-4 panel-header",
"style": {
"marginLeft": "{{Math.min((comment.depth ?? 0) + 1, 10) * 1}}rem"
}
@@ -101,7 +101,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-2 mb-2"
"className": "flex-start gap-2 mb-2"
},
"children": [
{
@@ -116,7 +116,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
"className": "text-label"
},
"text": "$t:sirsoft-board.admin.posts.detail.replying_to|name={{comment.author.name}}"
}
@@ -130,7 +130,7 @@
"placeholder": "$t:sirsoft-board.admin.posts.detail.reply_placeholder",
"rows": 2,
"disabled": "{{post?.data?.abilities?.can_write_comments !== true}}",
"className": "w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white mb-2 disabled:opacity-50 disabled:cursor-not-allowed"
"className": "text-primary w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 mb-2 disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
@@ -154,7 +154,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
"className": "px-3 py-1.5 text-body bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
@@ -257,7 +257,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -275,7 +275,7 @@
"if": "{{_local.replySubmitting}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
},
{
@@ -318,7 +318,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.posts.detail.no_comments"
}
@@ -337,7 +337,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
"className": "flex-start gap-3"
},
"children": [
{
@@ -363,7 +363,7 @@
"placeholder": "$t:sirsoft-board.admin.posts.detail.comment_placeholder",
"rows": 3,
"disabled": "{{post?.data?.abilities?.can_write_comments !== true}}",
"className": "w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
"className": "text-primary w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
@@ -461,7 +461,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -479,7 +479,7 @@
"if": "{{_local.commentSubmitting}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
},
{
@@ -19,12 +19,12 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-row items-start justify-between gap-4 mb-4"
"className": "flex-start flex-row justify-between gap-4 mb-4"
},
"responsive": {
"portable": {
"props": {
"className": "flex flex-col gap-3 mb-4"
"className": "row-stack mb-4"
}
}
},
@@ -42,12 +42,12 @@
"type": "basic",
"name": "H2",
"props": {
"className": "text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2 flex-wrap"
"className": "text-primary text-xl font-semibold flex-center gap-2 flex-wrap"
},
"responsive": {
"portable": {
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 flex-wrap"
"className": "section-heading flex-center gap-2 flex-wrap"
}
}
},
@@ -134,12 +134,12 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 ml-auto"
"className": "flex-center gap-2 ml-auto"
}
}
},
@@ -149,7 +149,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"className": "text-secondary inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{post?.data?.abilities?.can_write !== true}}"
},
"if": "{{post?.data?.board?.use_reply && !post?.data?.is_notice && (post?.data?.depth ?? 0) < (post?.data?.board?.max_reply_depth ?? 1) && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at}}",
@@ -185,7 +185,7 @@
"name": "Button",
"if": "{{post?.data?.board?.use_report && post?.data?.author?.uuid !== current_user?.data?.uuid && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at && !post?.data?.is_already_reported}}",
"props": {
"className": "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
"className": "text-secondary inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
},
"actions": [
{
@@ -224,7 +224,7 @@
"if": "{{post?.data?.board?.use_report && post?.data?.author?.uuid !== current_user?.data?.uuid && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at && post?.data?.is_already_reported}}",
"props": {
"disabled": true,
"className": "inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-700 rounded cursor-not-allowed opacity-60"
"className": "text-hint inline-flex items-center gap-1 px-3 py-1.5 font-medium bg-gray-100 dark:bg-gray-700 rounded cursor-not-allowed opacity-60"
},
"children": [
{
@@ -336,7 +336,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
"className": "flex-center flex-wrap gap-2 text-label"
},
"children": [
{
@@ -379,7 +379,7 @@
"name": "Span",
"if": "{{post?.data?.author?.is_guest}}",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"text": "{{post?.data?.author?.name}}"
},
@@ -387,7 +387,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "({{post?.data?.author?.email}})",
"if": "{{post?.data?.author?.email}}"
@@ -434,7 +434,7 @@
"name": "Div",
"if": "{{post?.data?.board?.use_comment}}",
"props": {
"className": "flex items-center gap-1"
"className": "flex-center gap-1"
},
"children": [
{
@@ -463,7 +463,7 @@
"name": "Span",
"if": "{{post?.data?.ip_address}}",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "text-tertiary"
},
"text": "IP: {{post?.data?.ip_address}}"
}
@@ -476,7 +476,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
"className": "panel-header-row"
},
"if": "{{post?.data?.has_attachment}}",
"children": [
@@ -485,7 +485,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between cursor-pointer"
"className": "flex-between cursor-pointer"
},
"actions": [
{
@@ -502,7 +502,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "flex-center gap-2 text-body font-medium"
},
"children": [
{
@@ -547,11 +547,20 @@
{
"id": "attachment_card",
"type": "basic",
"name": "A",
"name": "Div",
"props": {
"href": "{{attachment?.download_url}}",
"className": "group flex items-center gap-2 px-2 py-1.5 bg-gray-50 dark:bg-gray-800/50 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer no-underline"
"className": "group flex-center gap-2 px-2 py-1.5 bg-gray-50 dark:bg-gray-800/50 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "downloadAttachment",
"params": {
"url": "{{attachment?.download_url}}",
"filename": "{{attachment?.original_filename}}"
}
}
],
"children": [
{
"comment": "이미지 썸네일 (이미지 파일인 경우) - preview_url 사용",
@@ -579,7 +588,7 @@
"name": "Div",
"if": "{{!attachment?.is_image}}",
"props": {
"className": "flex-shrink-0 w-8 h-8 rounded bg-white dark:bg-gray-700 flex items-center justify-center"
"className": "flex-center flex-shrink-0 w-8 h-8 rounded bg-white dark:bg-gray-700 justify-center"
},
"children": [
{
@@ -657,7 +666,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between mb-2 text-sm"
"className": "flex-between mb-2 text-sm"
},
"children": [
{
@@ -665,7 +674,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -692,7 +701,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -735,7 +744,7 @@
"name": "Span",
"if": "{{post?.data?.parent?.author?.is_guest}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "{{post?.data?.parent?.author?.name ?? '$t:sirsoft-board.admin.posts.unknown_author'}}"
},
@@ -765,7 +774,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-2"
"className": "flex-center gap-2 mb-2"
},
"children": [
{
@@ -805,7 +814,7 @@
"name": "A",
"props": {
"href": "/admin/board/{{route.slug}}/post/{{post?.data?.parent?.id ?? ''}}",
"className": "font-bold text-gray-900 dark:text-white hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-400 transition-colors flex items-center gap-1"
"className": "flex-center text-primary font-bold hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-400 transition-colors gap-1"
},
"children": [
{
@@ -851,7 +860,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400 mb-3 {{_local?.parentPostExpanded ? '' : 'line-clamp-2'}}"
"className": "text-label mb-3 {{_local?.parentPostExpanded ? '' : 'line-clamp-2'}}"
},
"children": [
{
@@ -902,7 +911,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-transparent border-0 p-0 cursor-pointer flex items-center gap-1"
"className": "flex-center text-label-subtle hover:text-gray-700 dark:hover:text-gray-200 bg-transparent border-0 p-0 cursor-pointer gap-1"
},
"actions": [
{
@@ -952,14 +961,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col items-center justify-center py-8 p-6 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md"
"className": "flex-center flex-col justify-center py-8 p-6 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-3 mb-2"
"className": "flex-center gap-3 mb-2"
},
"children": [
{
@@ -974,7 +983,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-lg font-medium text-gray-900 dark:text-white"
"className": "text-primary text-lg font-medium"
},
"text": "$t:sirsoft-board.admin.posts.detail.blinded_notice"
}
@@ -984,7 +993,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 mb-2"
"className": "text-label-subtle mb-2"
},
"text": "$t:sirsoft-board.admin.posts.detail.blinded_description"
},
@@ -993,7 +1002,7 @@
"name": "Div",
"if": "{{post?.data?.abilities?.can_manage && (post?.data?.action_logs ?? []).filter(log => log.action === 'blind').length > 0}}",
"props": {
"className": "flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4"
"className": "flex-center gap-2 text-label-subtle mb-4"
},
"children": [
{
@@ -1023,7 +1032,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"className": "px-4 py-2 text-body font-medium bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{post?.data?.abilities?.can_manage !== true}}"
},
"actions": [
@@ -1052,7 +1061,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-3 pb-2 border-b border-gray-200 dark:border-gray-700"
"className": "flex-center gap-2 text-label-subtle mb-3 pb-2 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
@@ -1079,7 +1088,7 @@
"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"
"className": "text-secondary prose dark:prose-invert max-w-none"
},
"default": [
{
@@ -1088,7 +1097,7 @@
"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"
"className": "text-secondary prose dark:prose-invert max-w-none"
}
}
]
@@ -1139,7 +1148,7 @@
"responsive": {
"portable": {
"props": {
"className": "flex flex-col divide-y divide-gray-200 dark:divide-gray-700"
"className": "divider-list flex flex-col"
}
}
},
@@ -1166,7 +1175,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-center gap-2"
},
"children": [
{
@@ -1181,7 +1190,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 mr-2"
"className": "text-tertiary font-medium mr-2"
},
"text": "$t:sirsoft-board.admin.posts.detail.prev"
},
@@ -1198,7 +1207,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-900 dark:text-white truncate"
"className": "text-primary text-sm truncate"
},
"text": "{{post?.data?.navigation?.prev?.title}}"
}
@@ -1228,7 +1237,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-end gap-2"
"className": "flex-center justify-end gap-2"
},
"children": [
{
@@ -1244,7 +1253,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-900 dark:text-white truncate"
"className": "text-primary text-sm truncate"
},
"text": "{{post?.data?.navigation?.next?.title}}"
},
@@ -1252,7 +1261,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 ml-2"
"className": "text-tertiary font-medium ml-2"
},
"text": "$t:sirsoft-board.admin.posts.detail.next"
},
@@ -19,12 +19,12 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-row items-start justify-between gap-4 mb-4"
"className": "flex-start flex-row justify-between gap-4 mb-4"
},
"responsive": {
"portable": {
"props": {
"className": "flex flex-col gap-3 mb-4"
"className": "row-stack mb-4"
}
}
},
@@ -42,12 +42,12 @@
"type": "basic",
"name": "H2",
"props": {
"className": "text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2 flex-wrap"
"className": "text-primary text-xl font-semibold flex-center gap-2 flex-wrap"
},
"responsive": {
"portable": {
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 flex-wrap"
"className": "section-heading flex-center gap-2 flex-wrap"
}
}
},
@@ -146,7 +146,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-gray-800 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-600 transition-colors"
"className": "flex-center gap-1.5 px-3 py-1.5 text-sm bg-gray-800 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-600 transition-colors"
},
"if": "{{item.id != route.id}}",
"actions": [
@@ -173,7 +173,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-2 text-sm text-gray-600 dark:text-gray-400"
"className": "flex-center flex-wrap gap-2 text-label"
},
"children": [
{
@@ -216,7 +216,7 @@
"name": "Span",
"if": "{{item.author?.is_guest}}",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
"className": "text-heading"
},
"text": "{{item.author?.name}}"
},
@@ -224,7 +224,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "({{item.author?.email}})",
"if": "{{item.author?.email}}"
@@ -261,7 +261,7 @@
"name": "Div",
"if": "{{(item.comment_count ?? 0) > 0}}",
"props": {
"className": "flex items-center gap-1"
"className": "flex-center gap-1"
},
"children": [
{
@@ -290,7 +290,7 @@
"name": "Span",
"if": "{{item.ip_address}}",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "text-tertiary"
},
"text": "IP: {{item.ip_address}}"
}
@@ -303,7 +303,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
"className": "panel-header-row"
},
"if": "{{item.has_attachment}}",
"children": [
@@ -312,7 +312,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between cursor-pointer"
"className": "flex-between cursor-pointer"
},
"actions": [
{
@@ -329,7 +329,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "flex-center gap-2 text-body font-medium"
},
"children": [
{
@@ -374,11 +374,20 @@
{
"id": "attachment_card",
"type": "basic",
"name": "A",
"name": "Div",
"props": {
"href": "{{attachment.download_url}}",
"className": "flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 hover:border-gray-300 dark:border-gray-600 dark:hover:border-gray-500 transition-all cursor-pointer group no-underline"
"className": "flex-center gap-2 px-3 py-2 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-700 hover:border-gray-300 dark:border-gray-600 dark:hover:border-gray-500 transition-all cursor-pointer group"
},
"actions": [
{
"type": "click",
"handler": "downloadAttachment",
"params": {
"url": "{{attachment.download_url}}",
"filename": "{{attachment.original_filename}}"
}
}
],
"children": [
{
"type": "composite",
@@ -392,7 +401,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-900 dark:text-white truncate flex-1"
"className": "text-primary text-sm truncate flex-1"
},
"text": "{{attachment.original_filename}} ({{attachment.size_formatted}})"
}
@@ -437,7 +446,7 @@
"if": "{{(item?.content?.length ?? 0) > 150}}",
"props": {
"type": "button",
"className": "mt-2 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
"className": "flex-center mt-2 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors gap-1"
},
"actions": [
{
@@ -468,4 +477,4 @@
]
}
]
}
}
@@ -8,7 +8,7 @@
"name": "Div",
"if": "{{form_meta?.data?.board?.use_file_upload}}",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
"className": "admin-card"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
@@ -22,7 +22,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-4"
"className": "flex-center gap-2 mb-4"
},
"children": [
{
@@ -37,68 +37,74 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white"
"className": "section-heading-md"
},
"text": "$t:sirsoft-board.admin.posts.form.attachments"
}
]
},
{
"type": "composite",
"name": "FileUploader",
"props": {
"disabled": "{{_computed.isReadOnly}}",
"collection": "post_attachments",
"maxFiles": "{{form_meta?.data?.board?.max_file_count ?? 5}}",
"maxSize": "{{form_meta?.data?.board?.max_file_size ?? 10}}",
"accept": "{{form_meta?.data?.board?.allowed_extensions ? '.' + form_meta.data.board.allowed_extensions.join(',.') : '.jpg,.jpeg,.png,.gif,.pdf,.zip'}}",
"initialFiles": "{{form_meta?.data?.attachments ?? []}}",
"autoUpload": false,
"uploadTriggerEvent": "upload:board_attachments",
"compact": true,
"confirmBeforeRemove": true,
"confirmRemoveTitle": "$t:sirsoft-board.admin.posts.form.confirm_remove_attachment_title",
"confirmRemoveMessage": "$t:sirsoft-board.admin.posts.form.confirm_remove_attachment_message",
"uploadParams": {
"temp_key": "{{_local.tempKey}}"
},
"apiEndpoints": {
"upload": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments",
"delete": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments/:id",
"reorder": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments/reorder"
}
},
"actions": [
"type": "basic",
"name": "Div",
"children": [
{
"event": "onFilesChange",
"type": "change",
"handler": "setState",
"if": "{{!_local.hasChanges}}",
"params": {
"target": "local",
"hasChanges": true
}
},
{
"event": "onUploadError",
"type": "change",
"handler": "sequence",
"type": "composite",
"name": "FileUploader",
"props": {
"disabled": "{{_computed.isReadOnly}}",
"collection": "post_attachments",
"maxFiles": "{{form_meta?.data?.board?.max_file_count ?? 5}}",
"maxSize": "{{form_meta?.data?.board?.max_file_size ?? 10}}",
"accept": "{{form_meta?.data?.board?.allowed_extensions ? '.' + form_meta.data.board.allowed_extensions.join(',.') : '.jpg,.jpeg,.png,.gif,.pdf,.zip'}}",
"initialFiles": "{{form_meta?.data?.attachments ?? []}}",
"autoUpload": false,
"uploadTriggerEvent": "upload:board_attachments",
"compact": true,
"confirmBeforeRemove": true,
"confirmRemoveTitle": "$t:sirsoft-board.admin.posts.form.confirm_remove_attachment_title",
"confirmRemoveMessage": "$t:sirsoft-board.admin.posts.form.confirm_remove_attachment_message",
"uploadParams": {
"temp_key": "{{_local.tempKey}}"
},
"apiEndpoints": {
"upload": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments",
"delete": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments/:id",
"reorder": "/api/modules/sirsoft-board/admin/board/{{route?.slug}}/attachments/reorder"
}
},
"actions": [
{
"event": "onFilesChange",
"type": "change",
"handler": "setState",
"if": "{{!_local.hasChanges}}",
"params": {
"target": "local",
"uploadError": "{{$args[0]}}",
"isSaving": false
"hasChanges": true
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{$args[0]}}",
"icon": "triangle-exclamation"
}
"event": "onUploadError",
"type": "change",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"uploadError": "{{$args[0]}}",
"isSaving": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{$args[0]}}",
"icon": "triangle-exclamation"
}
}
]
}
]
}
@@ -6,9 +6,6 @@
"id": "post_form_fields",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
@@ -22,261 +19,270 @@
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 space-y-6"
"className": "admin-card"
},
"children": [
{
"id": "author_section",
"type": "basic",
"name": "Div",
"if": "{{route?.id && form_meta?.data?.author}}",
"props": {
"className": "mb-6"
"className": "row-stack"
},
"children": [
{
"id": "author_section",
"type": "basic",
"name": "Div",
"if": "{{route?.id && form_meta?.data?.author}}",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 gap-4"
"className": ""
},
"children": [
{
"id": "field_author_name",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.posts.form.author_info"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-900 dark:text-white"
},
"text": "{{form_meta?.data?.author?.email ? form_meta.data.author.name + ' (' + form_meta.data.author.email + ')' : form_meta?.data?.author?.name}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{form_meta?.data?.author?.is_guest}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.posts.guest"
}
]
}
]
},
{
"id": "field_created_at",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.posts.form.created_at"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{form_meta?.data?.created_at}}"
}
]
}
]
}
]
}
]
},
{
"id": "post_options_section",
"type": "basic",
"name": "Div",
"if": "{{form_meta?.data?.board?.user_abilities?.can_manage || form_meta?.data?.board?.secret_mode !== 'disabled'}}",
"props": {
"className": "mb-6"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-gray-500 dark:text-gray-400 mb-3"
},
"text": "$t:sirsoft-board.admin.posts.form.post_options"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "grid grid-cols-1 md:grid-cols-2 gap-4"
},
"children": [
{
"id": "notice_card",
"type": "basic",
"name": "Label",
"if": "{{!_local.form?.parent_id}}",
"props": {
"disabled": "{{_computed.isReadOnly || !form_meta?.data?.board?.user_abilities?.can_manage}}",
"className": "bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 cursor-pointer hover:border-blue-400 dark:hover:border-blue-600 transition-colors"
"className": "grid-2col-responsive"
},
"children": [
{
"id": "field_author_name",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Input",
"name": "Label",
"props": {
"type": "checkbox",
"name": "is_notice",
"disabled": "{{_computed.isReadOnly || !form_meta?.data?.board?.user_abilities?.can_manage}}",
"className": "mt-0.5 w-4 h-4 text-blue-600 dark:text-blue-400 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
}
"className": "text-label-subtle block font-medium mb-1"
},
"text": "$t:sirsoft-board.admin.posts.form.author_info"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
"className": "flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"name": "Span",
"props": {
"className": "flex items-center gap-2 mb-1"
"className": "text-heading"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "bullhorn",
"className": "w-4 h-4 text-blue-600 dark:text-blue-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
},
"text": "$t:sirsoft-board.admin.posts.form.is_notice"
}
]
"text": "{{form_meta?.data?.author?.email ? form_meta.data.author.name + ' (' + form_meta.data.author.email + ')' : form_meta?.data?.author?.name}}"
},
{
"type": "basic",
"name": "P",
"name": "Span",
"if": "{{form_meta?.data?.author?.is_guest}}",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-board.admin.posts.form.notice_description"
"text": "$t:sirsoft-board.admin.posts.guest"
}
]
}
]
},
{
"id": "field_created_at",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-label-subtle block font-medium mb-1"
},
"text": "$t:sirsoft-board.admin.posts.form.created_at"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2 text-heading"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{form_meta?.data?.created_at}}"
}
]
}
]
}
]
}
]
},
{
"id": "post_options_section",
"type": "basic",
"name": "Div",
"if": "{{form_meta?.data?.board?.user_abilities?.can_manage || form_meta?.data?.board?.secret_mode !== 'disabled'}}",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-label-subtle font-medium mb-3"
},
"text": "$t:sirsoft-board.admin.posts.form.post_options"
},
{
"id": "secret_card",
"type": "basic",
"name": "Label",
"if": "{{form_meta?.data?.board?.secret_mode !== 'disabled'}}",
"name": "Div",
"props": {
"className": "bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 cursor-pointer hover:border-amber-400 dark:hover:border-amber-600 transition-colors"
"className": "grid-2col-responsive"
},
"children": [
{
"id": "notice_card",
"type": "basic",
"name": "Div",
"name": "Label",
"if": "{{!_local.form?.parent_id}}",
"props": {
"className": "flex items-start gap-3"
"disabled": "{{_computed.isReadOnly || !form_meta?.data?.board?.user_abilities?.can_manage}}",
"className": "bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 cursor-pointer hover:border-blue-400 dark:hover:border-blue-600 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "is_secret",
"disabled": "{{_computed.isReadOnly || form_meta?.data?.board?.secret_mode === 'always'}}",
"className": "mt-0.5 w-4 h-4 text-amber-600 bg-gray-100 border-gray-300 rounded focus:ring-amber-500 dark:focus:ring-amber-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
"className": "flex-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "is_notice",
"disabled": "{{_computed.isReadOnly || !form_meta?.data?.board?.user_abilities?.can_manage}}",
"className": "mt-0.5 w-4 h-4 text-blue-600 dark:text-blue-400 rounded dark:focus:ring-blue-600 dark:ring-offset-gray-800"
}
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-1"
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"name": "Div",
"props": {
"name": "lock",
"className": "w-4 h-4 text-amber-600 dark:text-amber-400"
}
"className": "flex-center gap-2 mb-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "bullhorn",
"className": "w-4 h-4 text-blue-600 dark:text-blue-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary text-sm font-semibold"
},
"text": "$t:sirsoft-board.admin.posts.form.is_notice"
}
]
},
{
"type": "basic",
"name": "Span",
"name": "P",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
"className": "text-tertiary"
},
"text": "$t:sirsoft-board.admin.posts.form.is_secret"
"text": "$t:sirsoft-board.admin.posts.form.notice_description"
}
]
}
]
}
]
},
{
"id": "secret_card",
"type": "basic",
"name": "Label",
"if": "{{form_meta?.data?.board?.secret_mode !== 'disabled'}}",
"props": {
"className": "bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 cursor-pointer hover:border-amber-400 dark:hover:border-amber-600 transition-colors"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-start gap-3"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"name": "is_secret",
"disabled": "{{_computed.isReadOnly || form_meta?.data?.board?.secret_mode === 'always'}}",
"className": "mt-0.5 w-4 h-4 text-amber-600 rounded focus:ring-amber-500 dark:focus:ring-amber-600 dark:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
}
},
{
"type": "basic",
"name": "P",
"name": "Div",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
"className": "flex-1"
},
"text": "$t:sirsoft-board.admin.posts.form.secret_description"
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2 mb-1"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "lock",
"className": "w-4 h-4 text-amber-600 dark:text-amber-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary text-sm font-semibold"
},
"text": "$t:sirsoft-board.admin.posts.form.is_secret"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-tertiary"
},
"text": "$t:sirsoft-board.admin.posts.form.secret_description"
}
]
}
]
}
@@ -301,7 +307,7 @@
"type": "basic",
"name": "Label",
"props": {
"className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "form-label"
},
"text": "$t:sirsoft-board.admin.posts.form.category"
},
@@ -312,7 +318,7 @@
"name": "category",
"value": "{{_local.form?.category ?? ''}}",
"disabled": "{{_computed.isReadOnly || !!_local.form?.parent_id}}",
"className": "block w-full sm:w-64 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
"className": "text-primary block w-full sm:w-64 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
},
"children": [
{
@@ -340,84 +346,99 @@
]
},
{
"id": "title_section",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-2"
"className": "row-stack"
},
"children": [
{
"id": "title_section",
"type": "basic",
"name": "Label",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-label-subtle block font-medium"
},
"text": "$t:sirsoft-board.admin.posts.form.title"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "title",
"value": "{{_local.form?.title ?? ''}}",
"disabled": "{{_computed.isReadOnly}}",
"placeholder": "$t:sirsoft-board.admin.posts.form.title_placeholder",
"className": "block focus:ring-1 dark:focus:ring-blue-400"
}
}
]
}
],
"props": {
"className": "block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-board.admin.posts.form.title"
"className": ""
}
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "title",
"value": "{{_local.form?.title ?? ''}}",
"disabled": "{{_computed.isReadOnly}}",
"placeholder": "$t:sirsoft-board.admin.posts.form.title_placeholder",
"className": "block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400"
}
}
]
},
{
"id": "content_section",
"type": "extension_point",
"name": "html_editor",
"props": {
"name": "content",
"placeholder": "$t:sirsoft-board.admin.posts.form.content_placeholder",
"label": "$t:sirsoft-board.admin.posts.form.content",
"rows": 15,
"showHtmlModeToggle": true
},
"default": [
{
"type": "composite",
"name": "HtmlEditor",
"id": "content_section",
"type": "extension_point",
"name": "html_editor",
"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",
"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
}
}
]
}
]
}
@@ -426,4 +447,4 @@
]
}
]
}
}
@@ -15,7 +15,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-3"
"className": "flex-center gap-2 mb-3"
},
"children": [
{
@@ -30,7 +30,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
"className": "text-primary text-sm font-semibold"
},
"text": "$t:sirsoft-board.admin.posts.form.reply_to"
},
@@ -39,7 +39,7 @@
"name": "Span",
"if": "{{form_meta?.data?.parent_post?.is_notice}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
"className": "text-danger-soft inline-flex items-center px-2 py-0.5 rounded font-medium bg-red-100 dark:bg-red-900"
},
"text": "$t:sirsoft-board.admin.posts.notice"
},
@@ -48,7 +48,7 @@
"name": "Span",
"if": "{{form_meta?.data?.parent_post?.is_secret}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300"
"className": "text-warning-soft inline-flex items-center px-2 py-0.5 rounded font-medium bg-yellow-100 dark:bg-yellow-900"
},
"text": "$t:sirsoft-board.admin.posts.secret"
}
@@ -58,14 +58,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-2"
"className": "flex-center gap-2 mb-2"
},
"children": [
{
"type": "basic",
"name": "H4",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-white"
"className": "section-heading-md"
},
"text": "{{form_meta?.data?.parent_post?.title}}"
},
@@ -94,13 +94,13 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-3"
"className": "flex-center gap-2 text-tertiary mb-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{form_meta?.data?.parent_post?.user?.name ?? '$t:sirsoft-board.admin.posts.unknown_author'}}"
"text": "{{form_meta?.data?.parent_post?.author?.name ?? '$t:sirsoft-board.admin.posts.unknown_author'}}"
},
{
"type": "basic",
@@ -148,7 +148,7 @@
"if": "{{(form_meta?.data?.parent_post?.content?.length ?? 0) > 150}}",
"props": {
"type": "button",
"className": "text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
"className": "flex-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors gap-1"
},
"actions": [
{
@@ -21,7 +21,7 @@
"responsive": {
"portable": {
"props": {
"className": "px-6 py-4 border rounded-lg {{report_detail?.data?.status === 'pending' || report_detail?.data?.status === 'review' || report_detail?.data?.status === 'rejected' || report_detail?.data?.status === 'suspended' || report_detail?.data?.status === 'deleted' ? 'flex flex-col gap-3' : ''}} {{report_detail?.data?.status === 'pending' ? 'bg-yellow-50 dark:bg-yellow-900 border-yellow-300 dark:border-yellow-800' : report_detail?.data?.status === 'review' ? 'bg-blue-50 dark:bg-blue-900 border-blue-300 dark:border-blue-800' : report_detail?.data?.status === 'rejected' ? 'bg-purple-50 dark:bg-purple-900 border-purple-200 dark:border-purple-700' : report_detail?.data?.status === 'deleted' ? 'bg-gray-200 dark:bg-gray-700 border-gray-400 dark:border-gray-600' : 'bg-red-50 dark:bg-red-900 border-red-300 dark:border-red-800'}}"
"className": "px-6 py-4 border rounded-lg {{report_detail?.data?.status === 'pending' || report_detail?.data?.status === 'review' || report_detail?.data?.status === 'rejected' || report_detail?.data?.status === 'suspended' || report_detail?.data?.status === 'deleted' ? 'row-stack' : ''}} {{report_detail?.data?.status === 'pending' ? 'bg-yellow-50 dark:bg-yellow-900 border-yellow-300 dark:border-yellow-800' : report_detail?.data?.status === 'review' ? 'bg-blue-50 dark:bg-blue-900 border-blue-300 dark:border-blue-800' : report_detail?.data?.status === 'rejected' ? 'bg-purple-50 dark:bg-purple-900 border-purple-200 dark:border-purple-700' : report_detail?.data?.status === 'deleted' ? 'bg-gray-200 dark:bg-gray-700 border-gray-400 dark:border-gray-600' : 'bg-red-50 dark:bg-red-900 border-red-300 dark:border-red-800'}}"
}
}
},
@@ -41,7 +41,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 mb-1"
"className": "flex-center gap-2 mb-1"
},
"children": [
{
@@ -93,12 +93,12 @@
"type": "basic",
"name": "H3",
"props": {
"className": "font-semibold text-base {{report_detail?.data?.status === 'pending' ? 'text-yellow-700 dark:text-yellow-300' : report_detail?.data?.status === 'review' ? 'text-blue-700 dark:text-blue-300' : report_detail?.data?.status === 'rejected' ? 'text-purple-700 dark:text-purple-200' : report_detail?.data?.status === 'deleted' ? 'text-gray-900 dark:text-gray-300' : 'text-red-700 dark:text-red-300'}}"
"className": "font-semibold text-base {{report_detail?.data?.status === 'pending' ? 'text-warning-soft' : report_detail?.data?.status === 'review' ? 'text-info-soft' : report_detail?.data?.status === 'rejected' ? 'text-purple-700 dark:text-purple-200' : report_detail?.data?.status === 'deleted' ? 'text-gray-900 dark:text-gray-300' : 'text-danger-soft'}}"
},
"responsive": {
"portable": {
"props": {
"className": "font-semibold text-sm {{report_detail?.data?.status === 'pending' ? 'text-yellow-700 dark:text-yellow-300' : report_detail?.data?.status === 'review' ? 'text-blue-700 dark:text-blue-300' : report_detail?.data?.status === 'rejected' ? 'text-purple-700 dark:text-purple-200' : report_detail?.data?.status === 'deleted' ? 'text-gray-900 dark:text-gray-300' : 'text-red-700 dark:text-red-300'}}"
"className": "font-semibold text-sm {{report_detail?.data?.status === 'pending' ? 'text-warning-soft' : report_detail?.data?.status === 'review' ? 'text-info-soft' : report_detail?.data?.status === 'rejected' ? 'text-purple-700 dark:text-purple-200' : report_detail?.data?.status === 'deleted' ? 'text-gray-900 dark:text-gray-300' : 'text-danger-soft'}}"
}
}
},
@@ -139,12 +139,12 @@
"if": "{{report_detail?.data?.available_actions?.includes('pending') && report_detail?.data?.status !== 'rejected' && report_detail?.data?.status !== 'suspended'}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-2 px-3 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 text-xs font-medium transition-colors"
"className": "flex-center gap-2 px-3 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 text-xs font-medium transition-colors"
}
}
},
@@ -194,12 +194,12 @@
"if": "{{report_detail?.data?.available_actions?.includes('review')}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-xs font-medium transition-colors"
"className": "flex-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-xs font-medium transition-colors"
}
}
},
@@ -249,12 +249,12 @@
"if": "{{report_detail?.data?.available_actions?.includes('rejected')}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-2 px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-xs font-medium transition-colors"
"className": "flex-center gap-2 px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-xs font-medium transition-colors"
}
}
},
@@ -304,12 +304,12 @@
"if": "{{report_detail?.data?.available_actions?.includes('suspended')}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-xs font-medium transition-colors"
"className": "flex-center gap-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-xs font-medium transition-colors"
}
}
},
@@ -359,12 +359,12 @@
"if": "{{report_detail?.data?.available_actions?.includes('deleted')}}",
"props": {
"disabled": "{{report_detail?.data?.abilities?.can_manage !== true}}",
"className": "flex items-center gap-2 px-3 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-2 px-3 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"portable": {
"props": {
"className": "flex items-center gap-2 px-3 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium transition-colors"
"className": "flex-center gap-2 px-3 py-2 bg-gray-800 text-white rounded-lg hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium transition-colors"
}
}
},
@@ -7,7 +7,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6"
"className": "admin-card"
},
"children": [
{
@@ -15,91 +15,97 @@
"type": "basic",
"name": "H2",
"props": {
"className": "text-lg font-bold text-gray-900 dark:text-white mb-4"
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.reports.detail.histories_title"
},
{
"id": "history_data_grid",
"type": "composite",
"name": "DataGrid",
"if": "{{report_detail?.data?.histories && report_detail?.data?.histories.length > 0}}",
"props": {
"className": "histories-compact-grid",
"columns": [
{
"field": "created_at",
"header": "$t:sirsoft-board.admin.reports.detail.table.date",
"sortable": false,
"required": true,
"cellChildren": [
"type": "basic",
"name": "Div",
"children": [
{
"id": "history_data_grid",
"type": "composite",
"name": "DataGrid",
"if": "{{report_detail?.data?.histories && report_detail?.data?.histories.length > 0}}",
"props": {
"className": "histories-compact-grid",
"columns": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"text": "{{row.created_at}}"
}
]
},
{
"field": "processor_name",
"header": "$t:sirsoft-board.admin.reports.detail.table.processor",
"sortable": false,
"required": true,
"cellChildren": [
"field": "created_at",
"header": "$t:sirsoft-board.admin.reports.detail.table.date",
"sortable": false,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-tertiary"
},
"text": "{{row.created_at}}"
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-900 dark:text-white"
},
"text": "{{row.processor_name}}{{row.processor_email ? ' (' + row.processor_email + ')' : ''}}"
}
]
},
{
"field": "type",
"header": "$t:sirsoft-board.admin.reports.detail.table.action",
"sortable": false,
"required": true,
"cellChildren": [
"field": "processor_name",
"header": "$t:sirsoft-board.admin.reports.detail.table.processor",
"sortable": false,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-gray-900 dark:text-white"
},
"text": "{{row.processor_name}}{{row.processor_email ? ' (' + row.processor_email + ')' : ''}}"
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{row.type === 'reported' ? 'bg-blue-100 text-blue-800 dark:bg-blue-600 dark:text-white' : row.type === 're_reported' ? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-600 dark:text-white' : row.type === 'rejected' ? 'bg-purple-100 text-purple-800 dark:bg-purple-600 dark:text-white' : row.type === 'suspended' ? 'bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-white' : row.type === 'deleted' ? 'bg-gray-600 text-white dark:bg-gray-500 dark:text-white' : row.type === 'restored' ? 'bg-green-100 text-green-800 dark:bg-green-600 dark:text-white' : row.type === 'review' ? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-600 dark:text-white' : 'bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-100'}}"
},
"text": "{{row.type === 'reported' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.reported' : row.type === 're_reported' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.re_reported' : row.type === 'rejected' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.rejected' : row.type === 'suspended' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.suspended' : row.type === 'deleted' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.deleted' : row.type === 'restored' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.restored' : row.type === 'review' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.review' : row.action_label ?? row.type}}"
}
]
},
{
"field": "reason",
"header": "$t:sirsoft-board.admin.reports.detail.table.reason",
"sortable": false,
"required": true,
"cellChildren": [
"field": "type",
"header": "$t:sirsoft-board.admin.reports.detail.table.action",
"sortable": false,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{row.type === 'reported' ? 'bg-blue-100 text-blue-800 dark:bg-blue-600 dark:text-white' : row.type === 're_reported' ? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-600 dark:text-white' : row.type === 'rejected' ? 'bg-purple-100 text-purple-800 dark:bg-purple-600 dark:text-white' : row.type === 'suspended' ? 'bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-white' : row.type === 'deleted' ? 'bg-gray-600 text-white dark:bg-gray-500 dark:text-white' : row.type === 'restored' ? 'bg-green-100 text-green-800 dark:bg-green-600 dark:text-white' : row.type === 'review' ? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-600 dark:text-white' : 'bg-gray-100 text-gray-800 dark:bg-gray-600 dark:text-gray-100'}}"
},
"text": "{{row.type === 'reported' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.reported' : row.type === 're_reported' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.re_reported' : row.type === 'rejected' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.rejected' : row.type === 'suspended' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.suspended' : row.type === 'deleted' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.deleted' : row.type === 'restored' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.restored' : row.type === 'review' ? '$t:sirsoft-board.admin.reports.detail.history_timeline.review' : row.action_label ?? row.type}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line break-words max-w-md"
},
"text": "{{row.reason}}"
"field": "reason",
"header": "$t:sirsoft-board.admin.reports.detail.table.reason",
"sortable": false,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line break-words max-w-md"
},
"text": "{{row.reason}}"
}
]
}
]
],
"data": "{{report_detail?.data?.histories ?? []}}",
"sortable": false,
"pagination": false,
"selectable": false,
"showColumnSelector": false,
"responsiveBreakpoint": 768,
"emptyMessage": "$t:sirsoft-board.admin.reports.detail.no_histories"
}
],
"data": "{{report_detail?.data?.histories ?? []}}",
"sortable": false,
"pagination": false,
"selectable": false,
"showColumnSelector": false,
"responsiveBreakpoint": 768,
"emptyMessage": "$t:sirsoft-board.admin.reports.detail.no_histories"
}
}
]
},
{
"id": "history_empty",
@@ -114,11 +120,11 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.reports.detail.no_histories"
}
]
}
]
}
}
@@ -14,16 +14,13 @@
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"comment": "저장/되돌릴수없음 경고 배너",
"type": "basic",
"name": "Div",
"props": {
"className": "bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-center gap-3"
"className": "alert-warning"
},
"children": [
{
@@ -38,7 +35,9 @@
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-amber-800 dark:text-amber-200" },
"props": {
"className": "text-warning-soft"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.message"
}
]
@@ -49,7 +48,7 @@
"name": "Div",
"if": "{{(function() { const fields = _global.bulkApplySnapshot?.fields ?? []; return fields.some(f => f.includes('.') || f === 'manager'); })()}}",
"props": {
"className": "bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 flex items-center gap-3"
"className": "alert-info"
},
"children": [
{
@@ -64,7 +63,9 @@
{
"type": "basic",
"name": "P",
"props": { "className": "text-sm text-blue-800 dark:text-blue-200" },
"props": {
"className": "text-info-soft"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.permission_notice"
}
]
@@ -74,14 +75,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
"className": "toggle-group"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-4 py-2.5 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2"
"className": "px-4 py-2.5 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex-center gap-2"
},
"children": [
{
@@ -97,7 +98,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "text-body font-medium"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.selected_fields"
},
@@ -149,7 +150,9 @@
{
"type": "basic",
"name": "Span",
"props": { "className": "text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wide" },
"props": {
"className": "text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wide"
},
"text": "$t:sirsoft-board.admin.settings.sections.{{sectionGroup.sectionKey}}"
}
]
@@ -157,7 +160,9 @@
{
"type": "basic",
"name": "Div",
"props": { "className": "px-3 py-2 flex flex-wrap gap-1.5" },
"props": {
"className": "px-3 py-2 flex flex-wrap gap-1.5"
},
"children": [
{
"type": "basic",
@@ -197,14 +202,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
"className": "toggle-group"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-4 py-2.5 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2"
"className": "px-4 py-2.5 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex-center gap-2"
},
"children": [
{
@@ -220,7 +225,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300"
"className": "text-body font-medium"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.selected_boards"
},
@@ -248,7 +253,7 @@
"name": "Span",
"if": "{{_global.bulkApplySnapshot?.applyAll !== false}}",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 italic"
"className": "text-label-subtle italic"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.all_boards"
},
@@ -290,13 +295,17 @@
"name": "Div",
"if": "{{$parent._local.bulkApplySaveError}}",
"props": {
"className": "flex items-center gap-2 text-sm text-red-600 dark:text-red-400 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
"className": "alert-danger text-red-600 dark:text-red-400"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "circle-exclamation", "className": "w-4 h-4 flex-shrink-0" }
"props": {
"name": "circle-exclamation",
"className": "flex-shrink-0",
"size": "sm"
}
},
{
"type": "basic",
@@ -305,11 +314,65 @@
}
]
},
{
"comment": "일괄 적용 중단(전체 롤백) 안내 - rolled_back 응답 시 모달 미닫힘 + 오류(red) 박스",
"type": "basic",
"name": "Div",
"if": "{{$parent._local.bulkApplyAborted}}",
"props": {
"className": "alert-danger"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-exclamation",
"className": "text-red-600 dark:text-red-400 flex-shrink-0",
"size": "sm"
}
},
{
"comment": "권한 실패(board 있음) — 게시판명을 굵게 강조 + suffix 문구",
"type": "basic",
"name": "Span",
"if": "{{$parent._local.bulkApplyAbortedBoard}}",
"props": {
"className": "text-red-700 dark:text-red-300"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "font-bold"
},
"text": "{{$parent._local.bulkApplyAbortedBoard}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.aborted_message_suffix"
}
]
},
{
"comment": "컬럼 등 기타 실패(board null) — generic 문구",
"type": "basic",
"name": "Span",
"if": "{{!$parent._local.bulkApplyAbortedBoard}}",
"props": {
"className": "text-red-700 dark:text-red-300"
},
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.aborted_message_generic"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 pt-2"
"className": "flex justify-end gap-3"
},
"children": [
{
@@ -318,7 +381,7 @@
"props": {
"type": "button",
"disabled": "{{$parent._local.isBulkApplying}}",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "btn btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
@@ -341,7 +404,7 @@
"props": {
"type": "button",
"disabled": "{{$parent._local.isBulkApplying || !(_global.bulkApplySnapshot?.fields ?? []).length || (_global.bulkApplySnapshot?.applyAll === false && !(_global.bulkApplySnapshot?.boardIds ?? []).length)}}",
"className": "flex items-center gap-1.5 px-4 py-2 bg-amber-600 dark:bg-amber-700 text-white rounded-lg hover:bg-amber-700 dark:hover:bg-amber-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center gap-1.5 px-4 py-2 bg-amber-600 dark:bg-amber-700 text-white rounded-lg hover:bg-amber-700 dark:hover:bg-amber-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
@@ -349,12 +412,14 @@
"handler": "sequence",
"actions": [
{
"comment": "스냅샷은 모달 열기 전(탭 버튼 클릭 시) 이미 _global에 저장됨 - 여기서는 진행 상태만 변경",
"comment": "스냅샷은 모달 열기 전(탭 버튼 클릭 시) 이미 _global에 저장됨 - 여기서는 진행 상태 변경 + 직전 롤백 안내 초기화(재시도 대비)",
"handler": "setState",
"params": {
"target": "$parent._local",
"isBulkApplying": true,
"bulkApplySaveError": false
"bulkApplySaveError": false,
"bulkApplyAborted": false,
"bulkApplyAbortedBoard": null
}
},
{
@@ -383,28 +448,62 @@
},
"onSuccess": [
{
"comment": "성공/롤백 공통 — 진행 상태 해제",
"handler": "setState",
"params": {
"target": "$parent._local",
"isBulkApplying": false,
"isBulkApplying": false
}
},
{
"comment": "성공(rolled_back=false): 적용 완료 → 상태 초기화 + 스냅샷 정리 + 모달 닫고 success 토스트",
"handler": "setState",
"if": "{{!response?.data?.rolled_back}}",
"params": {
"target": "$parent._local",
"bulkApplyFields": [],
"bulkApplyAll": true
"bulkApplyAll": true,
"bulkApplyAborted": false,
"bulkApplyAbortedBoard": null
}
},
{
"handler": "setState",
"if": "{{!response?.data?.rolled_back}}",
"params": {
"target": "global",
"bulkApplySnapshot": null
}
},
{ "handler": "closeModal" },
{
"handler": "closeModal",
"if": "{{!response?.data?.rolled_back}}"
},
{
"handler": "toast",
"if": "{{!response?.data?.rolled_back}}",
"params": {
"type": "success",
"message": "$t:sirsoft-board.admin.settings.bulk_apply_modal.success|count={{response?.data?.updated_count ?? 0}}"
}
},
{
"comment": "롤백(rolled_back=true): 전체 취소됨 → 모달 유지 + 안내 박스 표시(board 유무로 문구 분기) + warning 토스트",
"handler": "setState",
"if": "{{response?.data?.rolled_back}}",
"params": {
"target": "$parent._local",
"bulkApplyAborted": true,
"bulkApplyAbortedBoard": "{{response?.data?.board?.name ?? null}}"
}
},
{
"handler": "toast",
"if": "{{response?.data?.rolled_back}}",
"params": {
"type": "error",
"message": "$t:sirsoft-board.admin.settings.bulk_apply_modal.aborted_toast"
}
}
],
"onError": [
@@ -460,7 +559,7 @@
"if": "{{$parent._local.isBulkApplying}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
},
{
@@ -479,7 +578,10 @@
}
]
}
]
],
"props": {
"className": "flex flex-col gap-4 pt-2"
}
}
]
}
@@ -1,140 +1,140 @@
{
"meta": {
"is_partial": true,
"description": "게시판 본인인증 정책 삭제 확인 모달"
"meta": {
"is_partial": true,
"description": "게시판 본인인증 정책 삭제 확인 모달"
},
"id": "board_identity_policy_delete_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:admin.identity.policies.delete_title",
"size": "sm"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-700 dark:text-gray-300"
},
"text": "$t:admin.identity.policies.delete_confirm"
},
"id": "board_identity_policy_delete_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:admin.identity.policies.delete_title",
"size": "small"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "modal-footer-buttons"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-700 dark:text-gray-300"
},
"text": "$t:admin.identity.policies.delete_confirm"
"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.boardIdentityPolicyDeleting}}"
},
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "board_identity_policy_delete_modal"
}
],
"text": "$t:common.cancel"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
},
"children": [
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.boardIdentityPolicyDeleting}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"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.boardIdentityPolicyDeleting}}"
},
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "board_identity_policy_delete_modal"
}
],
"text": "$t:common.cancel"
"handler": "setState",
"params": {
"target": "global",
"boardIdentityPolicyDeleting": true
}
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.boardIdentityPolicyDeleting}}"
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/identity/policies/{{_global.boardIdentityPolicyDeleteId}}",
"params": {
"method": "DELETE"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"boardIdentityPolicyDeleting": false,
"boardIdentityPolicyDeleteId": null
}
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"boardIdentityPolicyDeleting": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/identity/policies/{{_global.boardIdentityPolicyDeleteId}}",
"params": {
"method": "DELETE"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"boardIdentityPolicyDeleting": false,
"boardIdentityPolicyDeleteId": null
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:admin.identity.policies.delete_success"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boardIdentityPolicies"
}
},
{
"handler": "closeModal",
"target": "board_identity_policy_delete_modal"
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"boardIdentityPolicyDeleting": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message ?? '$t:admin.identity.policies.delete_failed'}}"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_global.boardIdentityPolicyDeleting}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.boardIdentityPolicyDeleting ? '$t:common.deleting' : '$t:common.delete'}}"
}
]
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:admin.identity.policies.delete_success"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "boardIdentityPolicies"
}
},
{
"handler": "closeModal",
"target": "board_identity_policy_delete_modal"
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"boardIdentityPolicyDeleting": false
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message ?? '$t:admin.identity.policies.delete_failed'}}"
}
}
]
}
]
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_global.boardIdentityPolicyDeleting}}",
"props": {
"name": "spinner",
"className": "spinner-sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{_global.boardIdentityPolicyDeleting ? '$t:common.deleting' : '$t:common.delete'}}"
}
]
}
]
]
}
]
}
@@ -1,7 +1,8 @@
{
"meta": {
"is_partial": true,
"description": "게시판 환경설정 > 본인인증 정책 탭 내 정책 추가/편집 모달. _global.identity_policy_form_modal 단일 진실 패턴 — list 가 매 진입마다 객체 통째 set, 모달 표시/입력 모두 _global 참조 (사례 13 회피 + modals 섹션 isolated 스코프 회피)"
"description": "게시판 환경설정 > 본인인증 정책 탭 내 정책 추가/편집 모달. _global.identity_policy_form_modal 단일 진실 패턴 — list 가 매 진입마다 객체 통째 set, 모달 표시/입력 모두 _global 참조 (사례 13 회피 + modals 섹션 isolated 스코프 회피)",
"editor_label": "$t:sirsoft-board.admin.settings.identity.policy_form_editor_label"
},
"id": "identity_policy_form_modal",
"type": "composite",
@@ -32,7 +33,7 @@
"name": "Div",
"if": "{{_global.identity_policy_form_modal?.errors}}",
"props": {
"className": "p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg"
"className": "alert-danger"
},
"children": [
{
@@ -45,7 +46,7 @@
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
"className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
@@ -57,7 +58,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
"className": "text-error-strong"
},
"text": "{{_global.identity_policy_form_modal?.errors?.message ?? '$t:common.validation_error'}}"
},
@@ -77,7 +78,7 @@
"type": "basic",
"name": "Li",
"props": {
"className": "text-xs text-red-700 dark:text-red-300"
"className": "text-danger-soft"
},
"text": "{{msg}}"
}
@@ -97,7 +98,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.key"
},
{
@@ -107,7 +108,7 @@
"type": "text",
"value": "{{_global.identity_policy_form_modal?.form?.key ?? ''}}",
"disabled": "{{!_global.identity_policy_form_modal?.isNew && _global.identity_policy_form_modal?.form?.source_type !== 'admin'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.key ? '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 font-mono focus:ring-2 focus:ring-red-500 focus:border-red-500 disabled:bg-gray-100 dark:disabled:bg-gray-900' : '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 font-mono disabled:bg-gray-100 dark:disabled:bg-gray-900'}}"
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.key ? 'input-error font-mono disabled:bg-gray-100 dark:disabled:bg-gray-900' : 'font-mono disabled:bg-gray-100 dark:disabled:bg-gray-900'}}"
},
"actions": [
{
@@ -124,7 +125,7 @@
"type": "basic",
"name": "Span",
"if": "{{_global.identity_policy_form_modal?.errors?.errors?.key}}",
"props": { "className": "text-red-500 text-xs mt-1 block" },
"props": { "className": "form-error" },
"text": "{{_global.identity_policy_form_modal?.errors?.errors?.key?.[0] ?? ''}}"
}
]
@@ -133,7 +134,7 @@
{
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 md:grid-cols-2 gap-3" },
"props": { "className": "grid-2col-responsive" },
"children": [
{
"type": "basic",
@@ -142,7 +143,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.scope"
},
{
@@ -151,7 +152,7 @@
"props": {
"value": "{{_global.identity_policy_form_modal?.form?.scope ?? 'route'}}",
"disabled": "{{!_global.identity_policy_form_modal?.isNew && _global.identity_policy_form_modal?.form?.source_type !== 'admin'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.scope ? '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 disabled:bg-gray-100 dark:disabled:bg-gray-900' : '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 disabled:bg-gray-100 dark:disabled:bg-gray-900'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.scope ? 'input-error disabled:bg-gray-100 dark:disabled:bg-gray-900' : ' disabled:bg-gray-100 dark:disabled:bg-gray-900'}}",
"options": [
{ "value": "route", "label": "$t:admin.identity.policies.scope.route" },
{ "value": "hook", "label": "$t:admin.identity.policies.scope.hook" },
@@ -173,7 +174,7 @@
"type": "basic",
"name": "Span",
"if": "{{_global.identity_policy_form_modal?.errors?.errors?.scope}}",
"props": { "className": "text-red-500 text-xs mt-1 block" },
"props": { "className": "form-error" },
"text": "{{_global.identity_policy_form_modal?.errors?.errors?.scope?.[0] ?? ''}}"
}
]
@@ -185,7 +186,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.target"
},
{
@@ -195,7 +196,7 @@
"type": "text",
"value": "{{_global.identity_policy_form_modal?.form?.target ?? ''}}",
"disabled": "{{!_global.identity_policy_form_modal?.isNew && _global.identity_policy_form_modal?.form?.source_type !== 'admin'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.target ? '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 font-mono focus:ring-2 focus:ring-red-500 focus:border-red-500 disabled:bg-gray-100 dark:disabled:bg-gray-900' : '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 font-mono disabled:bg-gray-100 dark:disabled:bg-gray-900'}}"
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.target ? 'input-error font-mono disabled:bg-gray-100 dark:disabled:bg-gray-900' : 'font-mono disabled:bg-gray-100 dark:disabled:bg-gray-900'}}"
},
"actions": [
{
@@ -212,7 +213,7 @@
"type": "basic",
"name": "Span",
"if": "{{_global.identity_policy_form_modal?.errors?.errors?.target}}",
"props": { "className": "text-red-500 text-xs mt-1 block" },
"props": { "className": "form-error" },
"text": "{{_global.identity_policy_form_modal?.errors?.errors?.target?.[0] ?? ''}}"
}
]
@@ -223,7 +224,7 @@
{
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 md:grid-cols-2 gap-3" },
"props": { "className": "grid-2col-responsive" },
"children": [
{
"type": "basic",
@@ -232,7 +233,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.purpose"
},
{
@@ -240,7 +241,7 @@
"name": "Select",
"props": {
"value": "{{_global.identity_policy_form_modal?.form?.purpose ?? 'sensitive_action'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.purpose ? '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'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.purpose ? 'input-error' : ''}}",
"options": "{{(boardIdentityPurposes?.data ?? []).map(function(p) { return {value: p.id, label: p.label ?? p.id}; })}}"
},
"actions": [
@@ -258,7 +259,7 @@
"type": "basic",
"name": "Span",
"if": "{{_global.identity_policy_form_modal?.errors?.errors?.purpose}}",
"props": { "className": "text-red-500 text-xs mt-1 block" },
"props": { "className": "form-error" },
"text": "{{_global.identity_policy_form_modal?.errors?.errors?.purpose?.[0] ?? ''}}"
}
]
@@ -270,7 +271,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.provider_id"
},
{
@@ -278,7 +279,7 @@
"name": "Select",
"props": {
"value": "{{_global.identity_policy_form_modal?.form?.provider_id ?? ''}}",
"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",
"className": "",
"options": "{{[{value: '', label: '$t:admin.identity.policies.form.use_default_provider'}].concat((identityProviders?.data ?? []).map(function(p) { return {value: p.id, label: p.label}; }))}}"
},
"actions": [
@@ -300,7 +301,7 @@
{
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 md:grid-cols-2 gap-3" },
"props": { "className": "grid-2col-responsive" },
"children": [
{
"type": "basic",
@@ -309,7 +310,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.grace_minutes"
},
{
@@ -320,7 +321,7 @@
"value": "{{_global.identity_policy_form_modal?.form?.grace_minutes ?? 0}}",
"min": 0,
"max": 1440,
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.grace_minutes ? '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'}}"
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.grace_minutes ? 'input-error' : ''}}"
},
"actions": [
{
@@ -337,7 +338,7 @@
"type": "basic",
"name": "Span",
"if": "{{_global.identity_policy_form_modal?.errors?.errors?.grace_minutes}}",
"props": { "className": "text-red-500 text-xs mt-1 block" },
"props": { "className": "form-error" },
"text": "{{_global.identity_policy_form_modal?.errors?.errors?.grace_minutes?.[0] ?? ''}}"
}
]
@@ -349,7 +350,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.applies_to"
},
{
@@ -357,8 +358,7 @@
"name": "Select",
"props": {
"value": "{{_global.identity_policy_form_modal?.form?.applies_to ?? 'both'}}",
"disabled": "{{!_global.identity_policy_form_modal?.isNew && _global.identity_policy_form_modal?.form?.source_type !== 'admin'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.applies_to ? '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 disabled:bg-gray-100 dark:disabled:bg-gray-900' : '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 disabled:bg-gray-100 dark:disabled:bg-gray-900'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.applies_to ? 'input-error' : ''}}",
"options": [
{ "value": "self", "label": "$t:admin.identity.policies.applies_to.self" },
{ "value": "admin", "label": "$t:admin.identity.policies.applies_to.admin" },
@@ -380,7 +380,7 @@
"type": "basic",
"name": "Span",
"if": "{{_global.identity_policy_form_modal?.errors?.errors?.applies_to}}",
"props": { "className": "text-red-500 text-xs mt-1 block" },
"props": { "className": "form-error" },
"text": "{{_global.identity_policy_form_modal?.errors?.errors?.applies_to?.[0] ?? ''}}"
}
]
@@ -391,7 +391,54 @@
{
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 md:grid-cols-2 gap-3" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.priority"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"value": "{{_global.identity_policy_form_modal?.form?.priority ?? 100}}",
"min": 0,
"max": 65535,
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.priority ? 'input-error' : ''}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"identity_policy_form_modal.form.priority": "{{Number($event.target.value)}}"
}
}
]
},
{
"type": "basic",
"name": "P",
"props": { "className": "text-hint mt-1" },
"text": "$t:admin.identity.policies.form.priority_desc"
},
{
"type": "basic",
"name": "Span",
"if": "{{_global.identity_policy_form_modal?.errors?.errors?.priority}}",
"props": { "className": "form-error" },
"text": "{{_global.identity_policy_form_modal?.errors?.errors?.priority?.[0] ?? ''}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "grid-2col-responsive" },
"children": [
{
"type": "basic",
@@ -400,7 +447,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.fail_mode"
},
{
@@ -408,7 +455,7 @@
"name": "Select",
"props": {
"value": "{{_global.identity_policy_form_modal?.form?.fail_mode ?? 'block'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.fail_mode ? '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'}}",
"className": "{{_global.identity_policy_form_modal?.errors?.errors?.fail_mode ? 'input-error' : ''}}",
"options": [
{ "value": "block", "label": "$t:admin.identity.policies.fail_mode.block" },
{ "value": "log_only", "label": "$t:admin.identity.policies.fail_mode.log_only" }
@@ -429,7 +476,7 @@
"type": "basic",
"name": "Span",
"if": "{{_global.identity_policy_form_modal?.errors?.errors?.fail_mode}}",
"props": { "className": "text-red-500 text-xs mt-1 block" },
"props": { "className": "form-error" },
"text": "{{_global.identity_policy_form_modal?.errors?.errors?.fail_mode?.[0] ?? ''}}"
}
]
@@ -442,7 +489,7 @@
{
"type": "basic",
"name": "Span",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.enabled"
},
{
@@ -476,7 +523,7 @@
{
"type": "basic",
"name": "Label",
"props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" },
"props": { "className": "form-label" },
"text": "$t:admin.identity.policies.form.signup_stage"
},
{
@@ -484,7 +531,7 @@
"name": "Select",
"props": {
"value": "{{_global.identity_policy_form_modal?.form?.conditions?.signup_stage ?? ''}}",
"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",
"className": "",
"options": [
{ "value": "", "label": "$t:admin.identity.policies.form.signup_stage_unset" },
{ "value": "before_submit", "label": "$t:admin.identity.policies.form.signup_stage_before_submit" },
@@ -505,7 +552,7 @@
{
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" },
"props": { "className": "text-hint mt-1" },
"text": "$t:admin.identity.policies.form.signup_stage_desc"
}
]
@@ -516,7 +563,7 @@
"name": "Div",
"if": "{{!_global.identity_policy_form_modal?.isNew && _global.identity_policy_form_modal?.form?.source_type !== 'admin'}}",
"props": {
"className": "p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg text-xs text-yellow-800 dark:text-yellow-200"
"className": "alert-warning text-warning-soft"
},
"text": "$t:admin.identity.policies.form.declared_policy_notice"
}
@@ -527,7 +574,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end items-center gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 bg-white dark:bg-gray-800"
"className": "flex-center justify-end gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 bg-white dark:bg-gray-800"
},
"children": [
{
@@ -559,7 +606,7 @@
"props": {
"type": "button",
"disabled": "{{_global.identity_policy_form_modal?.isSaving}}",
"className": "flex items-center gap-2 px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg"
"className": "flex-center gap-2 px-4 py-2 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg"
},
"actions": [
{
@@ -0,0 +1,653 @@
{
"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": "alert-danger"
},
"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-error-strong"
},
"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-error-soft"
},
"text": "{{errMsg}}"
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2 mb-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label-subtle font-medium"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.type_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary text-sm font-semibold"
},
"text": "$t:sirsoft-board.admin.settings.mail_templates.types.{{$parent._local.editingTemplate?.type ?? ''}}"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-tertiary font-mono px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 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'}}",
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.subject"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-required"
},
"text": "*"
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"value": "{{$parent._local.editingTemplate?.subject?.ko ?? ''}}",
"className": "{{$parent._local.templateErrors?.['subject.ko'] ? 'input-error' : ''}}",
"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": "form-error-xs"
},
"text": "{{$parent._local.templateErrors?.['subject.ko']?.[0] ?? ''}}"
}
]
}
]
},
{
"id": "board_edit_subject_en",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editLang ?? $locale) === 'en'}}",
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"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'] ? 'input-error' : ''}}",
"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": "form-error-xs"
},
"text": "{{$parent._local.templateErrors?.['subject.en']?.[0] ?? ''}}"
}
]
}
]
},
{
"id": "board_edit_body_ko",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editLang ?? $locale) === 'ko'}}",
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.mail_templates.edit_modal.body"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-required"
},
"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": "form-error-xs"
},
"text": "{{$parent._local.templateErrors?.['body.ko']?.[0] ?? ''}}"
}
]
}
]
},
{
"id": "board_edit_body_en",
"type": "basic",
"name": "Div",
"if": "{{($parent._local.editLang ?? $locale) === 'en'}}",
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"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": "form-error-xs"
},
"text": "{{$parent._local.templateErrors?.['body.en']?.[0] ?? ''}}"
}
]
}
]
},
{
"id": "board_edit_is_active_toggle",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-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-body"
},
"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-body font-medium 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": "modal-footer-buttons items-center px-4 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg sticky bottom-0"
},
"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-body px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 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_mail_template_edit"
}
]
},
{
"id": "board_btn_save_template",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "flex-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"
}
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -8,7 +8,7 @@
"name": "Modal",
"props": {
"title": "$t:sirsoft-board.admin.settings.notification_definitions.reset_modal.title",
"size": "small"
"size": "sm"
},
"children": [
{
@@ -29,13 +29,17 @@
{
"type": "basic",
"name": "Span",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 block mb-1" },
"props": {
"className": "form-sub-label"
},
"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" },
"props": {
"className": "text-primary font-medium"
},
"text": "{{_global.resetTargetName ?? ''}}"
}
]
@@ -44,14 +48,14 @@
"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"
"className": "alert-danger"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
"className": "text-error-soft"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.reset_modal.warning_message"
}
@@ -61,7 +65,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-3 mt-6"
"className": "modal-footer-buttons"
},
"children": [
{
@@ -86,7 +90,7 @@
"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",
"className": "flex-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": [
@@ -105,7 +109,9 @@
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/notification-definitions/{{_global.resetTargetId}}/reset",
"params": { "method": "POST" },
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "setState",
@@ -118,7 +124,9 @@
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "boardNotificationDefinitions" }
"params": {
"dataSourceId": "boardNotificationDefinitions"
}
},
{
"handler": "toast",
@@ -159,7 +167,7 @@
"if": "{{_global.isResettingNotification}}",
"props": {
"name": "spinner",
"className": "w-4 h-4 animate-spin"
"className": "spinner-sm"
}
},
{
@@ -34,7 +34,7 @@
"name": "Div",
"if": "{{_global.board_notification_template_form_modal?.errors}}",
"props": {
"className": "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"
"className": "alert-danger"
},
"children": [
{
@@ -61,7 +61,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-medium text-red-800 dark:text-red-200"
"className": "text-error-strong"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.validation_error"
},
@@ -81,7 +81,7 @@
"type": "basic",
"name": "Li",
"props": {
"className": "text-sm text-red-700 dark:text-red-300"
"className": "text-error-soft"
},
"text": "{{errMsg}}"
}
@@ -98,14 +98,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-2"
"className": "flex-center flex-wrap gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-500 dark:text-gray-400"
"className": "text-label-subtle font-medium"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.type_label"
},
@@ -113,7 +113,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-semibold text-gray-900 dark:text-white"
"className": "text-primary text-sm font-semibold"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.types.{{_global.board_notification_template_form_modal?.definition?.type ?? ''}}"
},
@@ -121,7 +121,7 @@
"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"
"className": "text-tertiary font-mono px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded"
},
"text": "{{_global.board_notification_template_form_modal?.definition?.type ?? ''}}"
},
@@ -137,7 +137,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400"
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.channel_label"
},
@@ -170,9 +170,6 @@
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3"
},
"children": [
{
"type": "basic",
@@ -190,7 +187,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center justify-between"
"className": "flex-between"
},
"children": [
{
@@ -212,7 +209,10 @@
{
"type": "basic",
"name": "Icon",
"props": { "name": "fa-solid fa-xmark", "size": "sm" }
"props": {
"name": "fa-solid fa-xmark",
"size": "sm"
}
}
],
"actions": [
@@ -231,19 +231,17 @@
{
"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" },
"props": {
"className": "text-secondary block text-xs font-medium"
},
"text": "$t:admin.settings.notification_definitions.recipients.type_label"
},
{
@@ -253,10 +251,22 @@
"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" }
{
"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": [
@@ -276,21 +286,28 @@
"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 ?? ''}}"
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-secondary block text-xs font-medium"
},
"text": "$t:admin.settings.notification_definitions.recipients.relation_label"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label-subtle block w-full px-3 py-1.5 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md"
},
"text": "{{rcpt.relation ?? ''}}"
}
]
}
]
},
@@ -298,66 +315,73 @@
"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": "{{_global.board_notification_template_form_modal?.roleSearchResults ?? []}}",
"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": [
"name": "Div",
"children": [
{
"event": "onSearch",
"handler": "apiCall",
"debounce": 300,
"auth_required": true,
"target": "/api/admin/roles",
"params": {
"method": "GET",
"query": {
"search": "{{$args[0]}}",
"per_page": 50
}
"type": "basic",
"name": "Label",
"props": {
"className": "text-secondary block text-xs font-medium"
},
"onSuccess": [
"text": "$t:admin.settings.notification_definitions.recipients.role_label"
},
{
"type": "composite",
"name": "SearchableDropdown",
"props": {
"value": "{{rcpt.value ?? ''}}",
"options": "{{[...(_global.board_notification_template_form_modal?.roleSearchResults ?? []).filter(o => String(o.value) !== String(rcpt.value)), ...(rcpt.value ? [{ value: rcpt.value, label: rcpt.display_name ?? rcpt.value, description: rcpt.value }] : [])]}}",
"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": "global",
"board_notification_template_form_modal.roleSearchResults": "{{(response.data?.data ?? []).map(r => ({ value: r.identifier, label: r.name, description: r.identifier }))}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.roleSearchResults": []
}
}
]
},
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.roleSearchResults": "{{(response.data?.data ?? []).map(r => ({ value: r.identifier, label: r.name, description: r.identifier }))}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.roleSearchResults": []
"board_notification_template_form_modal.recipients": "{{(_global.board_notification_template_form_modal?.recipients ?? []).map((r, i) => i === rcptIdx ? {...r, value: $args[0]} : r)}}"
}
}
]
},
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.recipients": "{{(_global.board_notification_template_form_modal?.recipients ?? []).map((r, i) => i === rcptIdx ? {...r, value: $args[0]} : r)}}"
}
}
]
}
@@ -367,67 +391,74 @@
"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": "board_rcpt_users_{{rcptIdx}}",
"type": "composite",
"name": "SearchableDropdown",
"props": {
"multiple": true,
"value": "{{rcpt.value ?? []}}",
"options": "{{_global.board_notification_template_form_modal?.userSearchResults ?? []}}",
"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": [
"name": "Div",
"children": [
{
"event": "onSearch",
"handler": "apiCall",
"debounce": 300,
"auth_required": true,
"target": "/api/admin/users/search",
"params": {
"method": "GET",
"query": {
"keyword": "{{$args[0]}}"
}
"type": "basic",
"name": "Label",
"props": {
"className": "text-secondary block text-xs font-medium"
},
"onSuccess": [
"text": "$t:admin.settings.notification_definitions.recipients.users_label"
},
{
"id": "board_rcpt_users_{{rcptIdx}}",
"type": "composite",
"name": "SearchableDropdown",
"props": {
"multiple": true,
"value": "{{rcpt.value ?? []}}",
"options": "{{[...(rcpt.value ?? []).map((uid, i) => ({ value: uid, label: (rcpt.display_names ?? [])[i] ?? uid })), ...(_global.board_notification_template_form_modal?.userSearchResults ?? []).filter(o => !(rcpt.value ?? []).map(String).includes(String(o.value)))]}}",
"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": "global",
"board_notification_template_form_modal.userSearchResults": "{{(response.data ?? []).map(u => ({ value: u.uuid, label: u.name, description: u.email }))}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.userSearchResults": []
}
}
]
},
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.userSearchResults": "{{(response.data ?? []).map(u => ({ value: u.uuid, label: u.name, description: u.email }))}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.userSearchResults": []
"board_notification_template_form_modal.recipients": "{{(_global.board_notification_template_form_modal?.recipients ?? []).map((r, i) => i === rcptIdx ? {...r, value: $args[0]} : r)}}"
}
}
]
},
{
"event": "onChange",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.recipients": "{{(_global.board_notification_template_form_modal?.recipients ?? []).map((r, i) => i === rcptIdx ? {...r, value: $args[0]} : r)}}"
}
}
]
}
@@ -437,7 +468,9 @@
"type": "basic",
"name": "Div",
"if": "{{rcpt.type === 'role' || rcpt.type === 'specific_users'}}",
"props": { "className": "flex items-center gap-2" },
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "composite",
@@ -459,12 +492,16 @@
{
"type": "basic",
"name": "Span",
"props": { "className": "text-xs text-gray-600 dark:text-gray-400" },
"props": {
"className": "text-secondary text-xs"
},
"text": "$t:admin.settings.notification_definitions.recipients.exclude_trigger_user"
}
]
}
]
],
"props": {
}
}
]
}
@@ -528,46 +565,51 @@
"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"
},
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.subject"
"name": "Label",
"props": {
"className": "form-label"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.subject"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-required"
},
"text": "*"
}
]
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-red-500 dark:text-red-400 ml-1" },
"text": "*"
}
]
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"value": "{{_global.board_notification_template_form_modal?.template?.subject?.[_global.board_notification_template_form_modal?.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 dark:focus:ring-indigo-400 focus:border-indigo-500 dark:focus:border-indigo-400"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.template.subject": "{{Object.assign({}, _global.board_notification_template_form_modal?.template?.subject, {[_global.board_notification_template_form_modal?.editLang ?? $locale]: $event.target.value})}}"
}
"name": "Input",
"props": {
"type": "text",
"value": "{{_global.board_notification_template_form_modal?.template?.subject?.[_global.board_notification_template_form_modal?.editLang ?? $locale] ?? ''}}",
"className": ""
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.template.subject": "{{Object.assign({}, _global.board_notification_template_form_modal?.template?.subject, {[_global.board_notification_template_form_modal?.editLang ?? $locale]: $event.target.value})}}"
}
}
]
}
]
}
@@ -577,47 +619,52 @@
"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"
},
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.body"
"name": "Label",
"props": {
"className": "form-label"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.body"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-required"
},
"text": "*"
}
]
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-red-500 dark:text-red-400 ml-1" },
"text": "*"
}
]
},
{
"type": "composite",
"name": "HtmlEditor",
"props": {
"content": "{{_global.board_notification_template_form_modal?.template?.body?.[_global.board_notification_template_form_modal?.editLang ?? $locale] ?? ''}}",
"isHtml": true,
"showHtmlModeToggle": false,
"className": "min-h-[200px]"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.template.body": "{{Object.assign({}, _global.board_notification_template_form_modal?.template?.body, {[_global.board_notification_template_form_modal?.editLang ?? $locale]: $event.target.value})}}"
}
"type": "composite",
"name": "HtmlEditor",
"props": {
"content": "{{_global.board_notification_template_form_modal?.template?.body?.[_global.board_notification_template_form_modal?.editLang ?? $locale] ?? ''}}",
"isHtml": true,
"showHtmlModeToggle": false,
"className": "min-h-[200px]"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.template.body": "{{Object.assign({}, _global.board_notification_template_form_modal?.template?.body, {[_global.board_notification_template_form_modal?.editLang ?? $locale]: $event.target.value})}}"
}
}
]
}
]
}
@@ -628,45 +675,48 @@
"type": "basic",
"name": "Div",
"if": "{{_global.board_notification_template_form_modal?.template?.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": "{{_global.board_notification_template_form_modal?.template?.click_url ?? ''}}",
"placeholder": "/mypage/orders/{order_number}",
"className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md 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 dark:focus:border-blue-400"
},
"actions": [
"name": "Div",
"children": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.template.click_url": "{{$event.target.value || null}}"
}
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.notification_definitions.edit_modal.click_url"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"value": "{{_global.board_notification_template_form_modal?.template?.click_url ?? ''}}",
"placeholder": "/mypage/orders/{order_number}",
"className": "font-mono"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"board_notification_template_form_modal.template.click_url": "{{$event.target.value || null}}"
}
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:admin.settings.notification_definitions.edit_modal.click_url_hint"
}
]
},
{
"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"
}
]
},
@@ -683,7 +733,7 @@
"type": "basic",
"name": "H4",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
"className": "text-body font-medium mb-2"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.variables_title"
},
@@ -708,19 +758,25 @@
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono" },
"props": {
"className": "font-mono"
},
"text": "{"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono" },
"props": {
"className": "font-mono"
},
"text": "{{v.key}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono" },
"props": {
"className": "font-mono"
},
"text": "}"
},
{
@@ -741,7 +797,7 @@
"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"
"className": "flex-between pt-4 pb-4 border-t border-gray-200 dark:border-gray-700 mt-4 flex-shrink-0"
},
"children": [
{
@@ -753,7 +809,7 @@
"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",
"className": "text-body px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors",
"disabled": "{{_global.board_notification_template_form_modal?.isSaving ?? false}}"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.edit_modal.btn_preview",
@@ -802,7 +858,7 @@
"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",
"className": "text-body px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.board_notification_template_form_modal?.isSaving ?? false}}"
},
"text": "$t:common.cancel",
@@ -819,7 +875,7 @@
"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",
"className": "flex-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": "{{_global.board_notification_template_form_modal?.isSaving ?? false}}"
},
"children": [
@@ -827,7 +883,11 @@
"type": "basic",
"name": "Icon",
"if": "{{_global.board_notification_template_form_modal?.isSaving}}",
"props": { "name": "spinner", "className": "animate-spin", "size": "sm" }
"props": {
"name": "spinner",
"className": "animate-spin",
"size": "sm"
}
},
{
"type": "basic",
@@ -15,22 +15,16 @@
"id": "board_preview_content",
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
"className": "text-table-header"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.preview_modal.subject_label"
},
@@ -38,7 +32,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-base font-medium text-gray-900 dark:text-white bg-gray-50 dark:bg-gray-900 rounded-md px-3 py-2"
"className": "text-primary text-base font-medium bg-gray-50 dark:bg-gray-900 rounded-md px-3"
},
"text": "{{_global.board_notification_template_form_modal?.preview?.subject ?? ''}}"
}
@@ -47,15 +41,12 @@
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
"className": "text-table-header"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.preview_modal.body_label"
},
@@ -74,7 +65,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end pt-3 border-t border-gray-200 dark:border-gray-700"
"className": "flex justify-end border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
@@ -83,7 +74,7 @@
"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"
"className": "text-body px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
},
"text": "$t:common.close",
"actions": [
@@ -96,7 +87,9 @@
}
]
}
]
],
"props": {
}
}
]
}
@@ -7,9 +7,6 @@
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "mt-6 space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
@@ -23,119 +20,365 @@
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
"className": "admin-card"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-2 border-b-2 border-gray-200 dark:border-gray-700"
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.settings.sections.attachment"
},
{
"id": "field_use_file_upload",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('use_file_upload') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"id": "field_use_file_upload",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer" },
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('use_file_upload')}}" }, "actions": [{ "type": "change", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_file_upload') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_file_upload') : [...(_local.bulkApplyFields ?? []), 'use_file_upload']}}" } }] },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.use_file_upload" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_file_upload" }
]}
]
},
{ "type": "composite", "name": "Toggle", "props": { "name": "basic_defaults.use_file_upload", "className": "flex-shrink-0", "disabled": "{{_computed.isReadOnly}}" } }
]
}
]
},
{
"id": "field_max_file_size",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('max_file_size') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer" },
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('max_file_size')}}" }, "actions": [{ "type": "change", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('max_file_size') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'max_file_size') : [...(_local.bulkApplyFields ?? []), 'max_file_size']}}" } }] },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.max_file_size" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.max_file_size" }
]}
]
},
{ "type": "basic", "name": "Div", "props": { "className": "flex-shrink-0" }, "children": [
{ "type": "basic", "name": "Input", "props": { "type": "number", "name": "basic_defaults.max_file_size", "min": 1, "className": "{{(_local.errors?.['basic_defaults.max_file_size']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}", "disabled": "{{_computed.isReadOnly}}" } },
{ "type": "basic", "name": "Span", "if": "{{_local.errors?.['basic_defaults.max_file_size']}}", "props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" }, "text": "{{(Array.isArray(_local.errors?.['basic_defaults.max_file_size']) ? _local.errors['basic_defaults.max_file_size'][0] : _local.errors?.['basic_defaults.max_file_size']) ?? ''}}" }
]}
]
}
]
},
{
"id": "field_max_file_count",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('max_file_count') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "flex items-center gap-4 px-3 py-3" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 cursor-pointer" },
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('max_file_count')}}" }, "actions": [{ "type": "change", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('max_file_count') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'max_file_count') : [...(_local.bulkApplyFields ?? []), 'max_file_count']}}" } }] },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.max_file_count" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.max_file_count" }
]}
]
},
{ "type": "basic", "name": "Div", "props": { "className": "flex-shrink-0" }, "children": [
{ "type": "basic", "name": "Input", "props": { "type": "number", "name": "basic_defaults.max_file_count", "min": 1, "max": 20, "className": "{{(_local.errors?.['basic_defaults.max_file_count']) ? 'w-32 px-3 py-2 text-sm border border-red-500 dark:border-red-500 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white' : 'w-32 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white'}}", "disabled": "{{_computed.isReadOnly}}" } },
{ "type": "basic", "name": "Span", "if": "{{_local.errors?.['basic_defaults.max_file_count']}}", "props": { "className": "text-xs text-red-600 dark:text-red-400 block mt-1" }, "text": "{{(Array.isArray(_local.errors?.['basic_defaults.max_file_count']) ? _local.errors['basic_defaults.max_file_count'][0] : _local.errors?.['basic_defaults.max_file_count']) ?? ''}}" }
]}
]
}
]
},
{
"id": "field_allowed_extensions",
"type": "basic", "name": "Div",
"props": { "className": "{{(_local.bulkApplyFields ?? []).includes('allowed_extensions') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors" },
"children": [
{
"type": "basic", "name": "Div", "props": { "className": "px-3 py-2" },
"children": [
{
"type": "basic", "name": "Label", "props": { "className": "flex items-start gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer mb-2" },
"children": [
{ "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "checkbox flex-shrink-0 mt-0.5", "checked": "{{(_local.bulkApplyFields ?? []).includes('allowed_extensions')}}" }, "actions": [{ "type": "change", "handler": "setState", "params": { "target": "local", "bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('allowed_extensions') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'allowed_extensions') : [...(_local.bulkApplyFields ?? []), 'allowed_extensions']}}" } }] },
{ "type": "basic", "name": "Div", "children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-board.admin.settings.fields.allowed_extensions" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal" }, "text": "$t:sirsoft-board.admin.settings.fields.descriptions.allowed_extensions" }
]}
]
},
{
"type": "composite", "name": "TagInput",
"type": "basic",
"name": "Div",
"props": {
"name": "basic_defaults.allowed_extensions",
"placeholder": "$t:sirsoft-board.admin.settings.fields.allowed_extensions_placeholder",
"creatable": true,
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}"
}
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 text-heading flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('use_file_upload')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_file_upload') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_file_upload') : [...(_local.bulkApplyFields ?? []), 'use_file_upload']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.use_file_upload"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_file_upload"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.use_file_upload",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_max_file_size",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 text-body font-medium flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('max_file_size')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('max_file_size') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'max_file_size') : [...(_local.bulkApplyFields ?? []), 'max_file_size']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.max_file_size"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.max_file_size|min={{_local?.form?._meta?.limits?.max_file_size_min ?? 1}}|max={{_local?.form?._meta?.limits?.max_file_size_max ?? 200}}"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-shrink-0"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "basic_defaults.max_file_size",
"min": "{{_local?.form?._meta?.limits?.max_file_size_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.max_file_size_max ?? 200}}",
"className": "{{(_local.errors?.['basic_defaults.max_file_size']) ? 'w-32 input-error' : 'w-32'}}",
"disabled": "{{_computed.isReadOnly}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['basic_defaults.max_file_size']}}",
"props": {
"className": "text-xs text-red-600 dark:text-red-400 block mt-1"
},
"text": "{{(Array.isArray(_local.errors?.['basic_defaults.max_file_size']) ? _local.errors['basic_defaults.max_file_size'][0] : _local.errors?.['basic_defaults.max_file_size']) ?? ''}}"
}
]
}
]
}
]
},
{
"id": "field_max_file_count",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 text-body font-medium flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('max_file_count')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('max_file_count') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'max_file_count') : [...(_local.bulkApplyFields ?? []), 'max_file_count']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.max_file_count"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.max_file_count|min={{_local?.form?._meta?.limits?.max_file_count_min ?? 1}}|max={{_local?.form?._meta?.limits?.max_file_count_max ?? 20}}"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-shrink-0"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "basic_defaults.max_file_count",
"min": "{{_local?.form?._meta?.limits?.max_file_count_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.max_file_count_max ?? 20}}",
"className": "{{(_local.errors?.['basic_defaults.max_file_count']) ? 'w-32 input-error' : 'w-32'}}",
"disabled": "{{_computed.isReadOnly}}"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['basic_defaults.max_file_count']}}",
"props": {
"className": "text-xs text-red-600 dark:text-red-400 block mt-1"
},
"text": "{{(Array.isArray(_local.errors?.['basic_defaults.max_file_count']) ? _local.errors['basic_defaults.max_file_count'][0] : _local.errors?.['basic_defaults.max_file_count']) ?? ''}}"
}
]
}
]
}
]
},
{
"id": "field_allowed_extensions",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-3 py-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 text-body font-medium cursor-pointer mb-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('allowed_extensions')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('allowed_extensions') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'allowed_extensions') : [...(_local.bulkApplyFields ?? []), 'allowed_extensions']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.allowed_extensions"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.allowed_extensions"
}
]
}
]
},
{
"type": "composite",
"name": "TagInput",
"props": {
"name": "basic_defaults.allowed_extensions",
"placeholder": "$t:sirsoft-board.admin.settings.fields.allowed_extensions_placeholder",
"creatable": true,
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
}
@@ -7,9 +7,6 @@
"type": "basic",
"name": "Div",
"if": "{{(_global.activeBoardSettingsTab || query.tab || 'general') === 'basic_defaults'}}",
"props": {
"className": "space-y-6"
},
"classMap": {
"key": "{{_computed.isReadOnly ? 'readonly' : 'normal'}}",
"variants": {
@@ -18,441 +15,459 @@
}
},
"children": [
{
"comment": "========== 페이지 안내 배너 ==========",
"id": "page_info_banner",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-blue-700 dark:text-blue-300"
},
"text": "$t:sirsoft-board.admin.settings.page_info"
}
]
},
{
"comment": "========== 섹션 1: 기본 설정 ==========",
"id": "section_basic",
"type": "layout",
"name": "SectionLayout",
"props": {
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
"className": "admin-card"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-lg font-semibold text-gray-900 dark:text-white pb-3 mb-1 border-b-2 border-gray-200 dark:border-gray-700"
"className": "card-title"
},
"text": "$t:sirsoft-board.admin.settings.sections.basic"
},
{
"type": "basic",
"name": "P",
"name": "Div",
"props": {
"className": "text-sm text-gray-500 dark:text-gray-400 mb-4"
"className": "card-description"
},
"text": "$t:sirsoft-board.admin.settings.sections.basic_description"
},
{
"id": "field_type",
"comment": "========== 페이지 안내 배너 ==========",
"id": "page_info_banner",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('type') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
"className": "alert-info"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "circle-info",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5",
"size": "sm"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-info-soft"
},
"text": "$t:sirsoft-board.admin.settings.page_info"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "row-stack"
},
"children": [
{
"id": "field_type",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
"className": ""
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1"
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
"className": "flex-1"
},
"children": [
{
"type": "basic",
"name": "Label",
"name": "Div",
"props": {
"className": "flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white cursor-pointer"
"className": "flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"name": "Label",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0",
"checked": "{{(_local.bulkApplyFields ?? []).includes('type')}}"
"className": "flex-center gap-2 text-heading cursor-pointer"
},
"actions": [
"children": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('type') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'type') : [...(_local.bulkApplyFields ?? []), 'type']}}"
}
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0",
"checked": "{{(_local.bulkApplyFields ?? []).includes('type')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('type') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'type') : [...(_local.bulkApplyFields ?? []), 'type']}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.type"
}
]
},
{
"id": "settings_btn_manage_board_types",
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.type"
}
]
},
{
"id": "settings_btn_manage_board_types",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"variant": "ghost",
"size": "sm",
"className": "inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
"disabled": "{{_computed.isReadOnly}}"
},
"children": [
{
"type": "basic",
"name": "Icon",
"name": "Button",
"props": {
"name": "gear",
"size": "sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.board_types.manage"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
"type": "button",
"variant": "ghost",
"size": "sm",
"className": "inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
"disabled": "{{_computed.isReadOnly}}"
},
"children": [
{
"handler": "setState",
"params": {
"target": "global",
"btFormMode": "add",
"btEditingItem": null,
"btFormSlug": "",
"btFormName": {
"ko": "",
"en": ""
}
"type": "basic",
"name": "Icon",
"props": {
"name": "gear",
"size": "sm"
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board-types",
"params": {
"method": "GET"
},
"onSuccess": [
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.board_types.manage"
}
],
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"boardTypesList": "{{response.data}}"
"btFormMode": "add",
"btEditingItem": null,
"btFormSlug": "",
"btFormName": {
"ko": "",
"en": ""
}
}
},
{
"handler": "openModal",
"target": "board_type_manage_modal"
"handler": "apiCall",
"auth_required": true,
"target": "/api/modules/sirsoft-board/admin/board-types",
"params": {
"method": "GET"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"boardTypesList": "{{response.data}}"
}
},
{
"handler": "openModal",
"target": "board_type_manage_modal"
}
]
}
]
}
]
}
]
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 pl-6 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.type"
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.type",
"options": "{{board_types?.data?.map(t => ({value: t.slug, label: (t.name[_global.locale ?? $locale ?? 'ko'] ?? t.name.ko ?? t.slug) + ' (' + t.slug + ')'})) ?? []}}",
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_secret_mode",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('secret_mode') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('secret_mode')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('secret_mode') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'secret_mode') : [...(_local.bulkApplyFields ?? []), 'secret_mode']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.secret_mode"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
"className": "text-field-hint pl-6"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.secret_mode"
}
]
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.secret_mode",
"options": [
{ "value": "disabled", "label": "$t:sirsoft-board.admin.settings.options.secret_mode.disabled" },
{ "value": "enabled", "label": "$t:sirsoft-board.admin.settings.options.secret_mode.enabled" },
{ "value": "always", "label": "$t:sirsoft-board.admin.settings.options.secret_mode.always" }
],
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_show_view_count",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('show_view_count') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('show_view_count')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('show_view_count') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'show_view_count') : [...(_local.bulkApplyFields ?? []), 'show_view_count']}}"
}
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.type"
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.show_view_count"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.show_view_count"
}
]
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.type",
"options": "{{board_types?.data?.map(t => ({value: t.slug, label: (t.name[_global.locale ?? $locale ?? 'ko'] ?? t.name.ko ?? t.slug) + ' (' + t.slug + ')'})) ?? []}}",
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.show_view_count",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_use_report",
"type": "basic",
"name": "Div",
"props": {
"className": "{{(_local.bulkApplyFields ?? []).includes('use_report') ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'}} transition-colors"
},
"children": [
},
{
"id": "field_secret_mode",
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-4 px-3 py-3"
"className": ""
},
"children": [
{
"type": "basic",
"name": "Label",
"name": "Div",
"props": {
"className": "flex items-start gap-2 text-sm font-medium text-gray-900 dark:text-white flex-1 cursor-pointer"
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Input",
"name": "Label",
"props": {
"type": "checkbox",
"className": "checkbox flex-shrink-0 mt-0.5",
"checked": "{{(_local.bulkApplyFields ?? []).includes('use_report')}}"
"className": "flex-start gap-2 text-heading flex-1 cursor-pointer"
},
"actions": [
"children": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_report') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_report') : [...(_local.bulkApplyFields ?? []), 'use_report']}}"
}
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('secret_mode')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('secret_mode') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'secret_mode') : [...(_local.bulkApplyFields ?? []), 'secret_mode']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.secret_mode"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.secret_mode"
}
]
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "basic_defaults.secret_mode",
"options": [
{
"value": "disabled",
"label": "$t:sirsoft-board.admin.settings.options.secret_mode.disabled"
},
{
"value": "enabled",
"label": "$t:sirsoft-board.admin.settings.options.secret_mode.enabled"
},
{
"value": "always",
"label": "$t:sirsoft-board.admin.settings.options.secret_mode.always"
}
],
"className": "w-48 flex-shrink-0 text-sm",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
},
{
"id": "field_show_view_count",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"name": "Label",
"props": {
"className": "flex-start gap-2 text-heading flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.use_report"
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('show_view_count')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('show_view_count') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'show_view_count') : [...(_local.bulkApplyFields ?? []), 'show_view_count']}}"
}
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_report"
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.show_view_count"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.show_view_count"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.show_view_count",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
},
}
]
},
{
"id": "field_use_report",
"type": "basic",
"name": "Div",
"props": {
"className": ""
},
"children": [
{
"type": "composite",
"name": "Toggle",
"type": "basic",
"name": "Div",
"props": {
"name": "basic_defaults.use_report",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
"className": "flex-center gap-4"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "flex-start gap-2 text-heading flex-1 cursor-pointer"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox-aligned",
"checked": "{{(_local.bulkApplyFields ?? []).includes('use_report')}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"bulkApplyFields": "{{(_local.bulkApplyFields ?? []).includes('use_report') ? (_local.bulkApplyFields ?? []).filter(f => f !== 'use_report') : [...(_local.bulkApplyFields ?? []), 'use_report']}}"
}
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-board.admin.settings.fields.use_report"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-field-hint"
},
"text": "$t:sirsoft-board.admin.settings.fields.descriptions.use_report"
}
]
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "basic_defaults.use_report",
"className": "flex-shrink-0",
"disabled": "{{_computed.isReadOnly}}"
}
}
]
}
]
}
Loaded 100 of 3582 files, more files were not shown because too many files have changed in this diff. Show more