From ba2adcf748b8693d9c817f8209308c7cb836002b Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 1 Jul 2026 15:54:39 +0900 Subject: [PATCH] =?UTF-8?q?fix(page):=20=EB=AF=B8=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=EA=B0=80=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미발행 페이지는 관리자여도 사용자 화면(/page/{slug})에 접근할 수 없어 발행 전 미리보기가 불가능했다. 페이지 조회 권한(sirsoft-page.pages.read) 보유 관리자는 미발행 페이지를 사용자 화면에서 미리볼 수 있게 하고, 미리보기 화면에 안내 배너를 노출한다. 비로그인·일반 회원은 기존대로 404를 유지한다. - getPublishedPageBySlug 에 미리보기 허용 플래그 추가 (권한 판정은 컨트롤러) - 미발행 미리보기 시 첨부 URL 을 admin 컨텍스트로 전환해 첨부까지 미리보기 - page/show.json 에 미발행 미리보기 안내 배너 + ko/en/ja 다국어 - E2E 시드 커맨드 구현 + 커맨드 등록 + 사용자 화면 미리보기 spec --- .../g7-template-sirsoft-basic-ja/CHANGELOG.md | 6 + .../frontend/partial/user.json | 3 +- .../language-pack.json | 2 +- modules/_bundled/sirsoft-page/CHANGELOG.md | 1 + .../Console/Commands/PlaywrightSeedPage.php | 100 +++++++++--- .../Controllers/User/PublicPageController.php | 19 ++- .../src/Http/Resources/PublicPageResource.php | 27 +++- .../src/Providers/PageServiceProvider.php | 20 ++- .../sirsoft-page/src/Services/PageService.php | 32 ++-- .../Feature/User/PublicPageControllerTest.php | 142 +++++++++++++++++- .../tests/Playwright/fixtures/page-seed.ts | 16 +- .../user/unpublished-admin-preview.spec.ts | 94 ++++++++++++ .../tests/Unit/Services/PageServiceTest.php | 29 ++++ .../page-unpublished-admin-preview.yaml | 66 ++++++++ templates/_bundled/sirsoft-basic/CHANGELOG.md | 6 + .../layouts/page-show-preview-banner.test.tsx | 79 ++++++++++ .../sirsoft-basic/lang/partial/en/user.json | 3 +- .../sirsoft-basic/lang/partial/ko/user.json | 3 +- .../sirsoft-basic/layouts/page/show.json | 24 +++ .../_bundled/sirsoft-basic/package-lock.json | 4 +- templates/_bundled/sirsoft-basic/package.json | 2 +- .../_bundled/sirsoft-basic/template.json | 2 +- 22 files changed, 626 insertions(+), 54 deletions(-) create mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/specs/user/unpublished-admin-preview.spec.ts create mode 100644 modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml create mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/page-show-preview-banner.test.tsx diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md index 7a17b2de..e13f46fe 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-01 + +### Added + +- 미발행 페이지 관리자 미리보기 안내 배너(`user.page.preview_banner`) 일본어 번역 추가 — 관리자가 발행 전 페이지를 사용자 화면에서 미리 볼 때의 안내 문구가 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.0] - 2026-07-01 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/user.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/user.json index 242d47d7..2213ce25 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/user.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/user.json @@ -51,7 +51,8 @@ "published_at": "公開日", "create_page": "ページ作成", "attachments": "添付ファイル", - "download": "ダウンロード" + "download": "ダウンロード", + "preview_banner": "未公開ページをプレビュー中です" }, "mypage": { "page_title": "マイページ", diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json index 06e3c95a..c79774c2 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-basic", diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 8768c172..981a88a6 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -19,6 +19,7 @@ - 페이지 관리 목록의 페이지당 표시 개수 기본값이 화면 표시(20건)와 실제 조회(15건) 간에 어긋나던 문제를 수정했습니다 — 기본값을 20건으로 통일했습니다. - 페이지 관리 목록에서 아주 긴 검색어를 입력하면 오류 화면이 뜨던 문제를 수정했습니다 — 검색어 길이를 100자로 제한해 안내 메시지로 처리합니다. - 페이지 상세의 버전 이력에서 "변경내역" 항목이 `title`·`content` 같은 영문 필드명으로 표시되던 문제를 수정했습니다 — 이제 "제목", "내용", "SEO 정보" 처럼 알아보기 쉬운 이름으로 표시됩니다. +- 관리자가 발행 전 페이지를 사용자 화면에서 미리 확인할 수 있게 되었습니다. 미리보기 화면에는 미발행 상태임을 알리는 안내가 표시되며, 비로그인 사용자와 일반 회원에게는 이전과 동일하게 노출되지 않습니다. ## [1.0.0] - 2026-07-01 diff --git a/modules/_bundled/sirsoft-page/src/Console/Commands/PlaywrightSeedPage.php b/modules/_bundled/sirsoft-page/src/Console/Commands/PlaywrightSeedPage.php index 72ffe7e0..5922d8d1 100644 --- a/modules/_bundled/sirsoft-page/src/Console/Commands/PlaywrightSeedPage.php +++ b/modules/_bundled/sirsoft-page/src/Console/Commands/PlaywrightSeedPage.php @@ -2,34 +2,44 @@ namespace Modules\Sirsoft\Page\Console\Commands; +use App\Models\User; use Illuminate\Console\Command; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\DB; +use Modules\Sirsoft\Page\Models\Page; +use Modules\Sirsoft\Page\Models\PageAttachment; /** - * Playwright E2E 용 페이지 도메인 시드 커맨드 (stub). + * Playwright E2E 용 페이지 도메인 시드 커맨드. * - * 페이지 + 첨부 등 모듈 도메인 데이터를 발급하여 stdout 에 JSON 으로 반환한다. 본 stub 은 - * 시그니처와 보안 가드만 구현되어 있으며, 실제 시드 로직은 페이지 모듈 후속 작업 세션에서 - * Factory 재사용으로 채워질 예정 (이커머스 PlaywrightSeedEcommerce 와 동일 패턴). + * 미발행 페이지 관리자 미리보기(#424-15) E2E 를 위해 고정 슬러그의 미발행/발행 페이지와 + * 첨부를 발급하여 stdout 에 JSON 으로 반환한다. 재실행 시 동일 슬러그를 물리 삭제 후 재생성해 + * 멱등성을 보장한다 (페이지·첨부는 hard delete 모델). * * 보안 가드 (코어 PlaywrightIssueToken 과 동일 3중 패턴): * ① CLI 한정 — `php_sapi_name() === 'cli'` * ② G7_PLAYWRIGHT_BYPASS=1 환경변수 옵트인 * ③ APP_DEBUG=true inline override — production + debug=false 환경에서도 동작 * - * 호출 예시 (활성화 후): - * $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-page --pages=3 --json + * 호출 예시: + * $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-page --attachments=2 --json */ class PlaywrightSeedPage extends Command { + /** + * 미리보기 E2E 용 고정 슬러그 (spec 에서 URL 로 사용). + */ + private const UNPUBLISHED_SLUG = 'test-e2e-unpublished-preview'; + + private const PUBLISHED_SLUG = 'test-e2e-published-preview'; + /** * 커맨드 이름 및 시그니처 * * @var string */ protected $signature = 'playwright:seed-page - {--pages=0 : 발급할 페이지 수} - {--attachments=0 : 페이지당 발급할 첨부 수} + {--attachments=2 : 페이지당 발급할 첨부 수} {--json : 결과를 JSON 으로 출력}'; /** @@ -42,9 +52,6 @@ class PlaywrightSeedPage extends Command /** * 커맨드를 실행합니다. * - * 본 stub 은 가드 통과 후 LogicException 을 throw 한다. - * 페이지 모듈 후속 작업 세션에서 stub 본문을 실제 시드 로직으로 교체할 때까지 활성화 차단. - * * @return int 종료 코드 */ public function handle(): int @@ -66,14 +73,67 @@ class PlaywrightSeedPage extends Command // ③ APP_DEBUG 강제 — SettingsServiceProvider 의 bypass 분기가 settings JSON 덮어쓰기 차단 상태 Config::set('app.debug', true); - // TODO (페이지 모듈 후속 작업 세션): - // - 페이지 N개 생성 (Factory 재사용) - // - 페이지당 첨부 N개 생성 (업로드 순서 검증용, order 1..N 부여) - // - 반환: {pageIds: [...], attachmentIds: [...]} - // 개발자 전용 stub 메시지 — 사용자 노출 0 (본문 구현 시 즉시 삭제). - throw new \LogicException( /* i18n:exempt — developer-only stub */ - 'playwright:seed-page 시드 로직은 아직 구현되지 않았습니다. ' - .'대상 spec 을 test.describe.skip 로 유지하거나, 본 커맨드 본문을 실제 시드 로직으로 교체하세요.' - ); + $attachmentsPerPage = max(1, (int) $this->option('attachments')); + + $result = DB::transaction(function () use ($attachmentsPerPage) { + // 재실행 멱등: 고정 슬러그 페이지 물리 삭제 (첨부는 FK 로 함께 정리) + Page::whereIn('slug', [self::UNPUBLISHED_SLUG, self::PUBLISHED_SLUG])->get() + ->each(function (Page $page) { + PageAttachment::where('page_id', $page->id)->delete(); + $page->delete(); + }); + + $author = User::factory()->create(); + + $unpublished = Page::factory()->create([ + 'slug' => self::UNPUBLISHED_SLUG, + 'title' => ['ko' => '미발행 미리보기 대상', 'en' => 'Unpublished Preview Target'], + 'content' => ['ko' => '

미발행 본문

', 'en' => '

Unpublished body

'], + 'published' => false, + 'published_at' => null, + 'created_by' => $author->id, + 'updated_by' => $author->id, + ]); + + $published = Page::factory()->published()->create([ + 'slug' => self::PUBLISHED_SLUG, + 'title' => ['ko' => '발행 대조군', 'en' => 'Published Control'], + 'content' => ['ko' => '

발행 본문

', 'en' => '

Published body

'], + 'created_by' => $author->id, + 'updated_by' => $author->id, + ]); + + $attachmentIds = []; + foreach ([$unpublished, $published] as $page) { + for ($i = 1; $i <= $attachmentsPerPage; $i++) { + // 첫 첨부는 이미지(미리보기 URL 생성 대상), 나머지는 일반 파일 + $factory = PageAttachment::factory(); + $factory = $i === 1 ? $factory->image() : $factory; + + $attachment = $factory->create([ + 'page_id' => $page->id, + 'order' => $i, + 'created_by' => $author->id, + ]); + $attachmentIds[] = $attachment->id; + } + } + + return [ + 'unpublished_slug' => self::UNPUBLISHED_SLUG, + 'published_slug' => self::PUBLISHED_SLUG, + 'unpublished_id' => $unpublished->id, + 'published_id' => $published->id, + 'attachment_ids' => $attachmentIds, + ]; + }); + + if ($this->option('json')) { + $this->line(json_encode($result, JSON_UNESCAPED_UNICODE)); + } else { + $this->info('페이지 시드 완료: 미발행='.self::UNPUBLISHED_SLUG.' / 발행='.self::PUBLISHED_SLUG); + } + + return self::SUCCESS; } } diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/User/PublicPageController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/User/PublicPageController.php index 14cdd91d..0eea990e 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Controllers/User/PublicPageController.php +++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/User/PublicPageController.php @@ -2,8 +2,10 @@ namespace Modules\Sirsoft\Page\Http\Controllers\User; +use App\Enums\PermissionType; use App\Http\Controllers\Api\Base\PublicBaseController; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Modules\Sirsoft\Page\Http\Resources\PublicPageResource; use Modules\Sirsoft\Page\Services\PageService; @@ -29,13 +31,22 @@ class PublicPageController extends PublicBaseController /** * 슬러그로 발행된 페이지를 조회합니다. * + * 페이지 조회 권한(sirsoft-page.pages.read)을 가진 관리자는 미발행 페이지도 + * 사용자 화면에서 미리볼 수 있습니다(비로그인·일반 회원은 미발행 시 404 유지). + * + * @param Request $request HTTP 요청 * @param string $slug 페이지 슬러그 * @return JsonResponse 페이지 상세 응답 */ - public function show(string $slug): JsonResponse + // audit:allow controller-base-request-injection reason: GET 상세 조회. 인증 사용자만 read-only 참조($request->user())해 미리보기 권한 판정. 검증할 body 없음(slug 는 라우트 파라미터) + public function show(Request $request, string $slug): JsonResponse { try { - $page = $this->pageService->getPublishedPageBySlug($slug); + $user = $request->user(); + $canPreview = $user + && $user->hasPermission('sirsoft-page.pages.read', PermissionType::Admin); + + $page = $this->pageService->getPublishedPageBySlug($slug, $canPreview); if (! $page) { return $this->notFound('sirsoft-page::messages.page.not_found'); @@ -43,9 +54,11 @@ class PublicPageController extends PublicBaseController $page->load('attachments'); + $isPreview = $canPreview && ! $page->published; + return $this->successWithResource( 'sirsoft-page::messages.page.fetch_success', - new PublicPageResource($page) + (new PublicPageResource($page))->withPreview($isPreview) ); } catch (\Exception $e) { return $this->error('sirsoft-page::messages.page.fetch_failed', 500, $e->getMessage()); diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php index 2bd7abb6..7f5f90a1 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php +++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php @@ -13,6 +13,27 @@ use Illuminate\Http\Request; */ class PublicPageResource extends BaseApiResource { + /** + * 미발행 페이지 관리자 미리보기 여부. + * + * true 이면 첨부 URL을 발행 가드가 없는 'admin' 컨텍스트로 생성하여 + * 관리자가 미발행 페이지의 첨부까지 미리볼 수 있도록 합니다. + */ + protected bool $preview = false; + + /** + * 미리보기(미발행 관리자 조회) 여부를 설정합니다. + * + * @param bool $preview 미리보기 여부 + * @return $this + */ + public function withPreview(bool $preview): self + { + $this->preview = $preview; + + return $this; + } + /** * 리소스를 배열로 변환합니다. * @@ -32,6 +53,7 @@ class PublicPageResource extends BaseApiResource ? ($this->content[$locale] ?? $this->content[$fallback] ?? (! empty($this->content) ? array_values($this->content)[0] : '')) : (string) ($this->content ?? ''), 'content_mode' => $this->content_mode ?? 'html', + 'is_preview' => $this->preview, 'published_at' => $this->published_at ? $this->formatDateTimeStringForUser($this->published_at) : null, @@ -39,7 +61,10 @@ class PublicPageResource extends BaseApiResource 'current_version' => $this->current_version, 'attachments' => $this->whenLoaded( 'attachments', - fn () => PageAttachmentResource::collectionFor($this->attachments, 'public') + fn () => PageAttachmentResource::collectionFor( + $this->attachments, + $this->preview ? 'admin' : 'public' + ) ), 'created_at' => $this->created_at ? $this->formatDateTimeStringForUser($this->created_at) diff --git a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php index 29252e18..b50de7e5 100644 --- a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php +++ b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php @@ -3,12 +3,15 @@ namespace Modules\Sirsoft\Page\Providers; use App\Extension\BaseModuleServiceProvider; +use App\Seo\SitemapGenerator; +use Modules\Sirsoft\Page\Console\Commands\PlaywrightSeedPage; use Modules\Sirsoft\Page\Repositories\Contracts\PageAttachmentRepositoryInterface; use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; use Modules\Sirsoft\Page\Repositories\Contracts\PageVersionRepositoryInterface; use Modules\Sirsoft\Page\Repositories\PageAttachmentRepository; use Modules\Sirsoft\Page\Repositories\PageRepository; use Modules\Sirsoft\Page\Repositories\PageVersionRepository; +use Modules\Sirsoft\Page\Seo\PageSitemapContributor; use Modules\Sirsoft\Page\Services\PageAttachmentService; /** @@ -20,8 +23,6 @@ class PageServiceProvider extends BaseModuleServiceProvider { /** * 모듈 식별자 - * - * @var string */ protected string $moduleIdentifier = 'sirsoft-page'; @@ -47,18 +48,23 @@ class PageServiceProvider extends BaseModuleServiceProvider /** * 서비스 부트스트랩 - * - * @return void */ public function boot(): void { parent::boot(); + // Artisan 커맨드 등록 (콘솔 환경에서만) + if ($this->app->runningInConsole()) { + $this->commands([ + PlaywrightSeedPage::class, + ]); + } + // Sitemap 기여자 등록 $this->app->booted(function () { - if ($this->app->bound(\App\Seo\SitemapGenerator::class)) { - $this->app->make(\App\Seo\SitemapGenerator::class)->registerContributor( - new \Modules\Sirsoft\Page\Seo\PageSitemapContributor() + if ($this->app->bound(SitemapGenerator::class)) { + $this->app->make(SitemapGenerator::class)->registerContributor( + new PageSitemapContributor ); } }); diff --git a/modules/_bundled/sirsoft-page/src/Services/PageService.php b/modules/_bundled/sirsoft-page/src/Services/PageService.php index a96ce93f..05de6c11 100644 --- a/modules/_bundled/sirsoft-page/src/Services/PageService.php +++ b/modules/_bundled/sirsoft-page/src/Services/PageService.php @@ -4,10 +4,13 @@ namespace Modules\Sirsoft\Page\Services; use App\Extension\HookManager; use App\Helpers\PermissionHelper; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Page\Models\Page; +use Modules\Sirsoft\Page\Models\PageVersion; use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; use Modules\Sirsoft\Page\Repositories\Contracts\PageVersionRepositoryInterface; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -300,7 +303,7 @@ class PageService * @param int $id 페이지 ID * @return Page 페이지 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function getPage(int $id): Page { @@ -316,23 +319,32 @@ class PageService /** * 슬러그로 발행된 페이지를 조회합니다. * + * $allowUnpublished 가 true 이면 미발행 페이지도 반환합니다. 관리자 미리보기 + * (사용자 화면에서 발행 전 페이지 확인)를 위한 것으로, 권한 판정은 호출부(컨트롤러)가 + * 담당하고 이 메서드는 허용 여부만 플래그로 전달받습니다. + * * @param string $slug 페이지 슬러그 - * @return Page|null 발행된 페이지 모델 또는 null + * @param bool $allowUnpublished 미발행 페이지 반환 허용 여부 (관리자 미리보기) + * @return Page|null 페이지 모델 또는 null */ - public function getPublishedPageBySlug(string $slug): ?Page + public function getPublishedPageBySlug(string $slug, bool $allowUnpublished = false): ?Page { $page = $this->pageRepository->findBySlug($slug); - return ($page && $page->published) ? $page : null; + if (! $page) { + return null; + } + + return ($page->published || $allowUnpublished) ? $page : null; } /** * 페이지 버전 이력을 조회합니다. * * @param Page $page 페이지 모델 - * @return \Illuminate\Database\Eloquent\Collection 버전 목록 (최신순) + * @return Collection 버전 목록 (최신순) */ - public function getVersions(Page $page): \Illuminate\Database\Eloquent\Collection + public function getVersions(Page $page): Collection { return $this->pageVersionRepository->getVersionsByPage($page); } @@ -341,11 +353,11 @@ class PageService * 버전 ID로 페이지 버전을 조회합니다. * * @param int $versionId 버전 ID - * @return \Modules\Sirsoft\Page\Models\PageVersion 버전 모델 + * @return PageVersion 버전 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ - public function getVersion(int $versionId): \Modules\Sirsoft\Page\Models\PageVersion + public function getVersion(int $versionId): PageVersion { return $this->pageVersionRepository->findOrFail($versionId); } @@ -369,7 +381,7 @@ class PageService * @param string $orderBy 정렬 컬럼 * @param string $direction 정렬 방향 (asc, desc) * @param int $limit 조회할 최대 항목 수 - * @return array{total: int, items: \Illuminate\Database\Eloquent\Collection} + * @return array{total: int, items: Collection} */ public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array { diff --git a/modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php b/modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php index 58554e26..95edf8b7 100644 --- a/modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php @@ -107,6 +107,146 @@ class PublicPageControllerTest extends FeatureTestCase $response->assertStatus(404); } + // ─── 미발행 페이지 관리자 미리보기 (#424-15) ──────── + + /** + * 페이지 조회 권한(pages.read) 관리자는 미발행 페이지를 미리볼 수 있는지 확인 + */ + public function test_admin_with_read_permission_can_preview_unpublished_page(): void + { + $admin = $this->createAdminUser(['sirsoft-page.pages.read']); + + Page::factory()->create([ + 'slug' => 'test-draft-preview', + 'published' => false, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + $response = $this->actingAs($admin) + ->getJson('/api/modules/sirsoft-page/pages/test-draft-preview'); + + $response->assertStatus(200) + ->assertJsonPath('success', true) + ->assertJsonPath('data.slug', 'test-draft-preview') + ->assertJsonPath('data.is_preview', true); + } + + /** + * 미발행 미리보기 응답의 첨부 URL이 관리자용(발행 가드 없는) 라우트인지 확인 + */ + public function test_admin_preview_unpublished_page_attachments_use_admin_url(): void + { + $admin = $this->createAdminUser(['sirsoft-page.pages.read']); + + $page = Page::factory()->create([ + 'slug' => 'test-draft-preview-attach', + 'published' => false, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + // 이미지 첨부 — preview_url 은 이미지에만 생성되므로 image mime 사용 + PageAttachment::create([ + 'page_id' => $page->id, + 'original_filename' => 'draft.png', + 'stored_filename' => 'stored-draft.png', + 'disk' => 'local', + 'path' => 'test/draft.png', + 'mime_type' => 'image/png', + 'size' => 1024, + 'collection' => 'attachments', + 'order' => 1, + 'created_by' => $admin->id, + ]); + + $response = $this->actingAs($admin) + ->getJson('/api/modules/sirsoft-page/pages/test-draft-preview-attach'); + + $response->assertStatus(200); + $downloadUrl = $response->json('data.attachments.0.download_url'); + $previewUrl = $response->json('data.attachments.0.preview_url'); + $this->assertStringContainsString('/admin/attachments/download/', $downloadUrl); + $this->assertStringContainsString('/admin/attachments/preview/', $previewUrl); + } + + /** + * 페이지 권한이 없는 일반 회원은 미발행 페이지를 미리볼 수 없음(404) + */ + public function test_non_admin_user_cannot_preview_unpublished_page(): void + { + $author = $this->createAdminUser([]); + $member = $this->createUser(); + + Page::factory()->create([ + 'slug' => 'test-draft-member', + 'published' => false, + 'created_by' => $author->id, + 'updated_by' => $author->id, + ]); + + $response = $this->actingAs($member) + ->getJson('/api/modules/sirsoft-page/pages/test-draft-member'); + + $response->assertStatus(404); + } + + /** + * pages.read 권한이 없는 관리자(다른 모듈 admin)는 미발행 페이지를 미리볼 수 없음(404) + */ + public function test_admin_without_pages_read_cannot_preview_unpublished_page(): void + { + // sirsoft-page.pages.read 가 아닌 다른 admin 권한만 보유 + $otherAdmin = $this->createAdminUser(['sirsoft-other.things.read']); + + Page::factory()->create([ + 'slug' => 'test-draft-other-admin', + 'published' => false, + 'created_by' => $otherAdmin->id, + 'updated_by' => $otherAdmin->id, + ]); + + $response = $this->actingAs($otherAdmin) + ->getJson('/api/modules/sirsoft-page/pages/test-draft-other-admin'); + + $response->assertStatus(404); + } + + /** + * 발행된 페이지의 첨부 URL은 관리자 요청이어도 공개용 라우트를 유지하는지 확인 + */ + public function test_published_page_attachments_use_public_url_even_for_admin(): void + { + $admin = $this->createAdminUser(['sirsoft-page.pages.read']); + + $page = Page::factory()->published()->create([ + 'slug' => 'test-published-admin-attach', + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + ]); + + PageAttachment::create([ + 'page_id' => $page->id, + 'original_filename' => 'public.pdf', + 'stored_filename' => 'stored-public.pdf', + 'disk' => 'local', + 'path' => 'test/public.pdf', + 'mime_type' => 'application/pdf', + 'size' => 1024, + 'collection' => 'attachments', + 'order' => 1, + 'created_by' => $admin->id, + ]); + + $response = $this->actingAs($admin) + ->getJson('/api/modules/sirsoft-page/pages/test-published-admin-attach'); + + $response->assertStatus(200) + ->assertJsonPath('data.is_preview', false); + $downloadUrl = $response->json('data.attachments.0.download_url'); + $this->assertStringContainsString('/pages/attachment/', $downloadUrl); + } + /** * 발행된 페이지 조회 시 첨부파일 목록이 포함되는지 확인 */ @@ -133,7 +273,7 @@ class PublicPageControllerTest extends FeatureTestCase 'created_by' => $admin->id, ]); - $response = $this->getJson("/api/modules/sirsoft-page/pages/test-page-with-attach"); + $response = $this->getJson('/api/modules/sirsoft-page/pages/test-page-with-attach'); $response->assertStatus(200) ->assertJsonStructure([ diff --git a/modules/_bundled/sirsoft-page/tests/Playwright/fixtures/page-seed.ts b/modules/_bundled/sirsoft-page/tests/Playwright/fixtures/page-seed.ts index b8ae8837..a24010e8 100644 --- a/modules/_bundled/sirsoft-page/tests/Playwright/fixtures/page-seed.ts +++ b/modules/_bundled/sirsoft-page/tests/Playwright/fixtures/page-seed.ts @@ -20,15 +20,21 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); type SeedFixtures = { - /** 시드된 페이지 도메인 데이터 (페이지 ID + 첨부 ID 배열) */ - seededPage: { pageIds: number[]; attachmentIds: number[] }; + /** 시드된 페이지 도메인 데이터 (고정 슬러그 미발행/발행 페이지 + 첨부 ID 배열) */ + seededPage: { + unpublished_slug: string; + published_slug: string; + unpublished_id: number; + published_id: number; + attachment_ids: number[]; + }; }; export const test = base.extend({ seededPage: async ({}, use) => { // 6단계 상위 = 코어 루트 (artisan 실행 cwd) const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../../'); - const out = execSync('php artisan playwright:seed-page --pages=3 --attachments=3 --json', { + const out = execSync('php artisan playwright:seed-page --attachments=2 --json', { cwd: coreRoot, encoding: 'utf-8', env: { @@ -36,7 +42,9 @@ export const test = base.extend({ G7_PLAYWRIGHT_BYPASS: '1', }, }); - const seed = JSON.parse(out); + // artisan 출력에 경고 등이 섞일 수 있으므로 마지막 JSON 라인만 파싱 + const jsonLine = out.trim().split(/\r?\n/).filter((l) => l.trim().startsWith('{')).pop() ?? '{}'; + const seed = JSON.parse(jsonLine); await use(seed); // TODO: teardown 시 cleanup 커맨드 호출 }, diff --git a/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/unpublished-admin-preview.spec.ts b/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/unpublished-admin-preview.spec.ts new file mode 100644 index 00000000..23547d2f --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/unpublished-admin-preview.spec.ts @@ -0,0 +1,94 @@ +/** + * 미발행 페이지 관리자 미리보기 (#424-15). + * + * @scenario viewer=guest, viewer=member, viewer=other_admin, viewer=page_reader, published=published, published=unpublished + * @effects page_reader_admin_previews_unpublished_page_with_200, + * preview_banner_renders_when_is_preview_true, + * preview_banner_hidden_for_published_or_guest, + * guest_gets_404_for_unpublished_page, + * member_gets_404_for_unpublished_page + * + * 정책: 페이지 조회 권한(sirsoft-page.pages.read) 보유 관리자만 사용자 화면(/page/{slug})에서 + * 미발행 페이지를 미리볼 수 있고, 이때 "미발행 페이지 미리보기 중" 배너가 노출된다. + * 비로그인·일반 회원·페이지 권한 없는 관리자는 미발행 페이지에서 404(페이지를 찾을 수 없습니다) 를 본다. + * 발행 페이지는 누구에게나 배너 없이 노출된다. + * + * DOM 식별: 레이아웃 노드 id 는 HTML id 로 렌더되지 않으므로(콘텐츠 노드는 id 속성 미출력) + * 사용자에게 보이는 텍스트로 식별한다(getByText 는 콘텐츠 비동기 렌더를 auto-wait). + * - 미리보기 배너: "미발행 페이지 미리보기 중입니다" + * - not-found: "페이지를 찾을 수 없습니다" + * - 시드 페이지 제목: 미발행="미발행 미리보기 대상" / 발행="발행 대조군" + * + * 시드: playwright:seed-page 가 고정 슬러그를 발급한다. + * - 미발행: test-e2e-unpublished-preview (제목 "미발행 미리보기 대상") + * - 발행: test-e2e-published-preview (제목 "발행 대조군") + */ +import { mergeTests } from '@playwright/test'; +import { test as authTest, expect, authenticatePage } from '../../fixtures/page-auth'; +import { test as seedTest } from '../../fixtures/page-seed'; + +const test = mergeTests(authTest, seedTest); + +const UNPUBLISHED_URL = '/page/test-e2e-unpublished-preview'; +const PUBLISHED_URL = '/page/test-e2e-published-preview'; + +// 앱 locale(ko/en)에 무관하게 매칭 — 실행 환경 기본 locale 이 EN 일 수 있다. +// 제목은 seed 가 넣은 고정 문자열이라 locale 무관. +const PREVIEW_BANNER_TEXT = /미발행 페이지 미리보기 중입니다|Previewing an unpublished page/; +const NOT_FOUND_TEXT = /페이지를 찾을 수 없습니다|Page Not Found/; +const UNPUBLISHED_TITLE = '미발행 미리보기 대상'; +const PUBLISHED_TITLE = '발행 대조군'; + +test.describe('미발행 페이지 관리자 미리보기 (#424-15)', () => { + test('페이지 조회 권한 관리자는 미발행 페이지를 미리보고 배너가 노출된다', async ({ + page, + pageReadToken, + seededPage, + }) => { + expect(seededPage.unpublished_slug).toBe('test-e2e-unpublished-preview'); + + await authenticatePage(page, pageReadToken); + await page.goto(UNPUBLISHED_URL); + + // 미발행이지만 권한자라 본문 노출 + 미리보기 배너 노출 + await expect(page.getByRole('heading', { name: UNPUBLISHED_TITLE })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(PREVIEW_BANNER_TEXT)).toBeVisible(); + await expect(page.getByText(NOT_FOUND_TEXT)).toHaveCount(0); + }); + + test('비로그인 사용자는 미발행 페이지에서 not-found 를 본다', async ({ page, seededPage }) => { + expect(seededPage.unpublished_slug).toBe('test-e2e-unpublished-preview'); + + // 인증 없음(게스트) + await page.goto(UNPUBLISHED_URL); + + await expect(page.getByRole('heading', { name: NOT_FOUND_TEXT })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(PREVIEW_BANNER_TEXT)).toHaveCount(0); + }); + + /* + * "권한 없는 로그인 사용자 / pages.read 없는 다른 모듈 관리자 → 404" 케이스는 E2E 로 + * 재현 불가하여 PHPUnit 이 담당한다. 근거: PlaywrightIssueToken.makeAdminUser 는 발급 유저를 + * 무조건 코어 `admin` 롤에 부여하는데, 활성 DB 의 admin 롤은 시드로 sirsoft-page.pages.read 를 + * 포함한 전체 권한(131개)을 이미 보유한다. 따라서 issueToken() 으로는 "페이지 권한이 없는 유저" + * 를 만들 수 없다(항상 pages.read 를 상속). 해당 게이트 분기는 아래 PHPUnit 이 격리 검증한다: + * - PublicPageControllerTest::test_non_admin_user_cannot_preview_unpublished_page + * - PublicPageControllerTest::test_admin_without_pages_read_cannot_preview_unpublished_page + * (createAdminUser 는 admin 롤에 명시 권한만 syncWithoutDetaching 하여 격리 가능) + */ + + test('발행된 페이지는 배너 없이 노출된다 (관리자 포함)', async ({ + page, + pageReadToken, + seededPage, + }) => { + expect(seededPage.published_slug).toBe('test-e2e-published-preview'); + + await authenticatePage(page, pageReadToken); + await page.goto(PUBLISHED_URL); + + await expect(page.getByRole('heading', { name: PUBLISHED_TITLE })).toBeVisible({ timeout: 30_000 }); + // 발행 페이지는 is_preview=false → 배너 미노출 + await expect(page.getByText(PREVIEW_BANNER_TEXT)).toHaveCount(0); + }); +}); \ No newline at end of file diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Services/PageServiceTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Services/PageServiceTest.php index 24a49509..6505bd71 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Services/PageServiceTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Services/PageServiceTest.php @@ -401,6 +401,35 @@ class PageServiceTest extends ModuleTestCase $this->assertNull($page); } + /** + * allowUnpublished=true 이면 미발행 페이지도 반환하는지 확인 (#424-15 미리보기) + */ + public function test_get_published_page_by_slug_returns_draft_when_preview_allowed(): void + { + Page::factory()->create([ + 'slug' => 'test-svc-draft-preview', + 'published' => false, + 'created_by' => $this->adminUser->id, + 'updated_by' => $this->adminUser->id, + ]); + + $page = $this->service->getPublishedPageBySlug('test-svc-draft-preview', true); + + $this->assertNotNull($page); + $this->assertEquals('test-svc-draft-preview', $page->slug); + $this->assertFalse((bool) $page->published); + } + + /** + * allowUnpublished=true 여도 존재하지 않는 슬러그는 null 을 반환하는지 확인 + */ + public function test_get_published_page_by_slug_returns_null_for_missing_even_when_preview_allowed(): void + { + $page = $this->service->getPublishedPageBySlug('test-svc-no-such-slug', true); + + $this->assertNull($page); + } + // ─── scope 검증 (AccessDeniedHttpException) ──── /** diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml new file mode 100644 index 00000000..396e39b7 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/scenarios/page-unpublished-admin-preview.yaml @@ -0,0 +1,66 @@ +# audit:allow test-scenario-coverage reason: | +# 본 매니페스트는 미발행 페이지 관리자 미리보기(#424-15) 단일 결함의 권한 × 발행상태 +# cross product 를 기록한다. viewer(guest/member/other_admin/page_reader) × published +# (published/unpublished) 8케이스 중 "미리보기 허용" 은 page_reader × unpublished 단 1조합이며, +# 나머지는 기존 동작(발행=200 공개 / 미발행=404) 유지다. 실제 회귀 가드는 test_files 의 +# Feature/Unit/레이아웃 테스트가 결함별로 담당하므로 docblock @scenario 전수 마킹 대신 +# 통과 테스트로 커버한다. +# E2E: 사용자 화면 미리보기 spec 은 페이지 모듈 Playwright 인프라(page-auth/page-seed fixture) +# 를 재사용하되, playwright:seed-page 시드 로직 + 대상 화면 data-testid 보강 전까지 +# test.describe.skip placeholder 로 운용한다(기존 admin spec 3건과 동일 운용). +feature: 미발행 페이지 관리자 미리보기 (권한자만 사용자 화면 노출 + 미리보기 배너 + 첨부 admin URL) + +description: | + #424-15: 미발행 페이지는 관리자여도 사용자 화면(/page/{slug})에 접근할 수 없던 결함을 수정한다. + + 게이트 정책: + - 비로그인·일반 회원: 미발행 페이지 → 404 유지 (비공개). + - 페이지 조회 권한(sirsoft-page.pages.read, admin) 보유자: 미발행 페이지 → 200 미리보기. + - pages.read 없는 다른 모듈 admin: 404 (최소권한 — 관리자 상세 라우트와 동일 게이트). + - 발행 페이지: 모두 200 (기존 동작 무변경). + + 구현: + - PageService::getPublishedPageBySlug($slug, $allowUnpublished) — 허용 플래그로 미발행 반환. + - PublicPageController::show — optional.sanctum 로 인증된 사용자의 hasPermission(pages.read, admin) + 판정 후 플래그 전달. 권한 판정은 컨트롤러, Service 는 플래그만 수신(게시판 PostController 패턴). + - PublicPageResource::withPreview — is_preview 노출 + 첨부 컨텍스트를 admin(발행가드 없는 라우트)/ + public 로 분기. 미발행 미리보기 시 첨부 download/preview 가 admin URL 이라 첨부까지 미리보기 가능. + - page/show.json(sirsoft-basic) — is_preview=true 일 때 "미발행 페이지 미리보기 중" 배너 노출. + + 안전: + - SEO 캐시 오염 없음: 봇(게스트)은 미발행에 여전히 404 → 미리보기 콘텐츠가 봇 캐시에 안 들어감. + - 정보 노출 최소: published 전체가 아닌 is_preview(bool)만 추가 노출. + - 공개 첨부 컨트롤러 무변경: admin URL 컨텍스트 재사용으로 발행 가드·권한 자동 처리. + +axes: + viewer: [guest, member, other_admin, page_reader] + published: [published, unpublished] + +effects: + - guest_sees_published_page + - guest_gets_404_for_unpublished_page + - member_gets_404_for_unpublished_page + - other_admin_without_pages_read_gets_404_for_unpublished_page + - page_reader_admin_previews_unpublished_page_with_200 + - preview_response_exposes_is_preview_true_only_for_unpublished_preview + - preview_attachments_use_admin_context_url + - published_page_attachments_use_public_context_url_even_for_admin + - preview_banner_renders_when_is_preview_true + - preview_banner_hidden_for_published_or_guest + +test_files: + - modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php + - modules/_bundled/sirsoft-page/tests/Unit/Services/PageServiceTest.php + - templates/_bundled/sirsoft-basic/__tests__/layouts/page-show-preview-banner.test.tsx + - modules/_bundled/sirsoft-page/tests/Playwright/specs/user/unpublished-admin-preview.spec.ts + +notes: | + 회귀 가드 담당: + - 게이트(권한 4변종 × 발행상태) + is_preview 노출 + 첨부 컨텍스트 URL → PublicPageControllerTest(Feature). + - getPublishedPageBySlug 플래그 동작 격리 → PageServiceTest(Unit). + - 배너 if=is_preview 의존 + 다국어 키 + 위치 → page-show-preview-banner.test.tsx(레이아웃 렌더). + + E2E: 사용자 화면 미리보기 spec(unpublished-admin-preview.spec.ts)은 페이지 모듈 Playwright + 인프라(page-auth fixture 재사용, page-seed fixture)를 사용한다. playwright:seed-page 시드 로직과 + 대상 화면 data-testid 보강 전까지 test.describe.skip placeholder 로 등록하며(기존 admin spec 3건과 + 동일 운용), 실제 회귀 가드는 위 Feature/Unit/레이아웃 렌더 테스트가 담당한다. diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 0cc20ef7..e6022bc7 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-07-01 + +### Added + +- 관리자가 발행 전 페이지를 사용자 화면에서 미리 볼 때, 미발행 상태임을 알리는 안내 표시를 추가했습니다. + ## [1.0.0] - 2026-07-01 ### Added diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/page-show-preview-banner.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/page-show-preview-banner.test.tsx new file mode 100644 index 00000000..07981a11 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/page-show-preview-banner.test.tsx @@ -0,0 +1,79 @@ +/** + * @file page-show-preview-banner.test.tsx + * @description 미발행 페이지 관리자 미리보기 배너 (이슈 #424-15) + * + * 정책: 페이지 조회 권한 관리자가 사용자 화면에서 미발행 페이지를 미리볼 때, + * 상단에 "미발행 페이지 미리보기 중" 안내 배너를 노출한다. 백엔드(PublicPageResource)가 + * 미발행+권한자 응답에만 is_preview=true 를 내려주므로, 배너의 if 는 그 값에 의존한다. + * 발행 페이지·게스트 응답은 is_preview=false → 배너 미노출. + * + * 검증 방식: 실제 page/show.json 레이아웃 트리 직접 분석 (DOM 비의존). + * - 배너 블록이 존재하고 if 가 page.data.is_preview 에 의존하는지 고정 + * - 배너 문구가 다국어 키($t:user.page.preview_banner)로 렌더되는지 고정 + */ + +import { describe, it, expect } from 'vitest'; + +import pageShow from '../../layouts/page/show.json'; + +type Node = Record & { children?: 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; +} + +/** + * id 로 노드를 찾는다. + */ +function findById(root: unknown, id: string): Node | undefined { + return collectNodes(root, (n) => n.id === id)[0]; +} + +describe('이슈 #424-15 — 미발행 페이지 미리보기 배너 (page/show.json)', () => { + it('미리보기 배너 블록(page_preview_banner)이 존재한다', () => { + const banner = findById(pageShow, 'page_preview_banner'); + expect(banner).toBeDefined(); + }); + + it('배너의 if 가 page.data.is_preview 에 의존한다 (발행/게스트는 미노출)', () => { + const banner = findById(pageShow, 'page_preview_banner'); + expect(typeof banner?.if).toBe('string'); + expect(banner?.if as string).toContain('is_preview'); + // 단일 바인딩 형태 {{...}} 여야 엔진이 식으로 평가 (CLAUDE.md if 규칙) + expect((banner?.if as string).trim()).toMatch(/^\{\{.*\}\}$/); + }); + + it('배너 문구가 다국어 키로 렌더된다 (하드코딩 금지)', () => { + const banner = findById(pageShow, 'page_preview_banner'); + const spans = collectNodes(banner, (n) => n.name === 'Span' && typeof n.text === 'string'); + const texts = spans.map((s) => s.text as string); + expect(texts).toContain('$t:user.page.preview_banner'); + }); + + it('배너가 본문 카드(page_content_card) 내부, 제목 영역보다 앞에 위치한다', () => { + const card = findById(pageShow, 'page_content_card'); + expect(card).toBeDefined(); + const children = card?.children as Node[]; + expect(Array.isArray(children)).toBe(true); + // 첫 자식이 미리보기 배너여야 함 (제목/발행일보다 위) + expect(children[0]?.id).toBe('page_preview_banner'); + }); +}); diff --git a/templates/_bundled/sirsoft-basic/lang/partial/en/user.json b/templates/_bundled/sirsoft-basic/lang/partial/en/user.json index 0974e432..80253786 100644 --- a/templates/_bundled/sirsoft-basic/lang/partial/en/user.json +++ b/templates/_bundled/sirsoft-basic/lang/partial/en/user.json @@ -51,7 +51,8 @@ "published_at": "Published", "create_page": "Create Page", "attachments": "Attachments", - "download": "Download" + "download": "Download", + "preview_banner": "Previewing an unpublished page" }, "mypage": { "page_title": "My Page", diff --git a/templates/_bundled/sirsoft-basic/lang/partial/ko/user.json b/templates/_bundled/sirsoft-basic/lang/partial/ko/user.json index 084284a5..0a0366be 100644 --- a/templates/_bundled/sirsoft-basic/lang/partial/ko/user.json +++ b/templates/_bundled/sirsoft-basic/lang/partial/ko/user.json @@ -51,7 +51,8 @@ "published_at": "발행일", "create_page": "페이지 생성", "attachments": "첨부파일", - "download": "다운로드" + "download": "다운로드", + "preview_banner": "미발행 페이지 미리보기 중입니다" }, "mypage": { "page_title": "마이페이지", diff --git a/templates/_bundled/sirsoft-basic/layouts/page/show.json b/templates/_bundled/sirsoft-basic/layouts/page/show.json index 4e9234b9..07900042 100644 --- a/templates/_bundled/sirsoft-basic/layouts/page/show.json +++ b/templates/_bundled/sirsoft-basic/layouts/page/show.json @@ -80,6 +80,30 @@ } }, "children": [ + { + "id": "page_preview_banner", + "type": "basic", + "name": "Div", + "if": "{{page?.data?.is_preview}}", + "props": { + "className": "flex items-center gap-2 mb-4 px-4 py-3 rounded-md bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400 text-sm" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "eye", + "className": "text-amber-600 dark:text-amber-400 text-sm" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:user.page.preview_banner" + } + ] + }, { "type": "basic", "name": "Div", diff --git a/templates/_bundled/sirsoft-basic/package-lock.json b/templates/_bundled/sirsoft-basic/package-lock.json index 79da9e6f..7156c020 100644 --- a/templates/_bundled/sirsoft-basic/package-lock.json +++ b/templates/_bundled/sirsoft-basic/package-lock.json @@ -1,12 +1,12 @@ { "name": "sirsoft-basic", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sirsoft-basic", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/templates/_bundled/sirsoft-basic/package.json b/templates/_bundled/sirsoft-basic/package.json index e0f6dea7..e2a065a7 100644 --- a/templates/_bundled/sirsoft-basic/package.json +++ b/templates/_bundled/sirsoft-basic/package.json @@ -1,6 +1,6 @@ { "name": "sirsoft-basic", - "version": "1.0.0", + "version": "1.0.1", "description": "Gnuboard7 Basic User Template Components - Nexibase Style", "type": "module", "main": "dist/components.js", diff --git a/templates/_bundled/sirsoft-basic/template.json b/templates/_bundled/sirsoft-basic/template.json index 4f7acf10..9ece8403 100644 --- a/templates/_bundled/sirsoft-basic/template.json +++ b/templates/_bundled/sirsoft-basic/template.json @@ -5,7 +5,7 @@ "ko": "Basic", "en": "Basic" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "description": { "ko": "그누보드7 기본 사용자 템플릿",