KIM-JINO5 преди 2 дни
родител
ревизия
b837d14f4f
променени са 2 файла, в които са добавени 440 реда и са изтрити 0 реда
  1. 378 0
      .claude/PRPs/plans/completed/main-photo-section-all-boards.plan.md
  2. 62 0
      .claude/PRPs/reports/main-photo-section-all-boards-report.md

+ 378 - 0
.claude/PRPs/plans/completed/main-photo-section-all-boards.plan.md

@@ -0,0 +1,378 @@
+# Plan: 메인 Photo Section — 전 게시판 미디어(이미지/동영상) 노출
+
+## Summary
+메인페이지 photo section이 현재 `photo` 게시판(일상/풍경/사진) 글만 노출한다. 이를 **모든 게시판**에서 이미지 또는 임베드 동영상(YouTube)이 포함된 최신 글의 대표 이미지(thumbnail)가 노출되도록 확장한다. 썸네일 데이터는 이미 저장 시점에 전 게시판에 대해 생성되고 있으므로(`tb_post.thumbnail`), **조회 쿼리와 뷰만 변경**하면 된다.
+
+## User Story
+As a 블로그 방문자,
+I want 메인페이지 포토 영역에서 게시판 구분 없이 이미지·동영상이 있는 최신 글을 한눈에 보고,
+So that 어떤 게시판에 올라온 미디어든 놓치지 않고 탐색할 수 있다.
+
+## Problem → Solution
+- 현재: `MainController::index` → `BoardService::latest('photo')` → `Board::getLatest`가 `BRD.code = 'photo'`로 제한 → 다른 게시판의 이미지/동영상 글은 미노출
+- 변경: 전 게시판 대상 + `thumbnail`이 존재하는 글만 조회하는 신규 메서드(`getLatestMedia` / `latestMedia`) 추가, 메인에서 그것을 사용
+
+## Metadata
+- **Complexity**: Small–Medium
+- **Source PRD**: N/A (free-form)
+- **PRD Phase**: N/A
+- **Estimated Files**: 5 (+1 선택)
+
+---
+
+## UX Design
+
+### Before
+```
+┌─ 메인페이지 ──────────────────────────────┐
+│ [일상/풍경/사진]              [전체보기→photo] │
+│ ┌───┐┌───┐┌───┐┌───┐                      │
+│ │img││img││img││img│  ← photo 게시판 글만    │
+│ └───┘└───┘└───┘└───┘                      │
+└──────────────────────────────────────────┘
+다른 게시판에 이미지/영상 글 → 안 보임
+```
+
+### After
+```
+┌─ 메인페이지 ──────────────────────────────┐
+│ [포토/미디어]              [전체보기→recently] │
+│ ┌───┐┌───┐┌───┐┌───┐                      │
+│ │img││ 🎬││img││img│ ← 전 게시판, 썸네일 有만 │
+│ └───┘└───┘└───┘└───┘                      │
+│  [게시판명] 제목 …                          │
+└──────────────────────────────────────────┘
+```
+
+### Interaction Changes
+| Touchpoint | Before | After | Notes |
+|---|---|---|---|
+| photo section 데이터 | photo 게시판 최신글 (썸네일 없어도 포함) | 전 게시판 썸네일 보유 글 | 썸네일 없는 글은 제외됨 |
+| section 제목 | "일상/풍경/사진" | "포토/미디어" (임의 변경 가능) | 사용자 취향 |
+| 전체보기 링크 | `route('board.list','photo')` | `route('recently')` | 전 게시판이므로 |
+| 캡션 라벨 | `[categoryName]` | `[boardName]` (있으면 category 우선) | 출처 게시판 표시 |
+
+---
+
+## Mandatory Reading
+
+| Priority | File | Lines | Why |
+|---|---|---|---|
+| P0 | `app/Models/Board.php` | 558–606 | `getLatest` — 미러링할 SQL 패턴 (raw SQL + @code 변수 + getPaginator) |
+| P0 | `app/Services/BoardService.php` | 156–175, 205–258 | `latest()` 캐시 패턴 + `_filter()` 가공 패턴 |
+| P0 | `app/Http/Controllers/MainController.php` | 38–62 | photo section 데이터 주입 지점 |
+| P1 | `resources/views/desktop/main.blade.php` | 136–175 | 데스크톱 photo section 마크업 |
+| P1 | `resources/views/mobile/main.blade.php` | 136–170 | 모바일 photo section 마크업 (photo-strip) |
+| P2 | `app/Http/Traits/CommonTrait.php` | 152–166 | `getThumbnail` — 썸네일 생성 규칙 (img → YouTube iframe → default) |
+| P2 | `app/Services/PostService.php` | 166–200, 258–300 | 저장 시 thumbnail/image_rows/video_rows 세팅 확인 |
+
+## External Documentation
+없음 — 내부 패턴만 사용. "No external research needed — feature uses established internal patterns."
+
+---
+
+## Patterns to Mirror
+
+### RAW_SQL_QUERY (Model)
+```php
+// SOURCE: app/Models/Board.php:558-606 (getLatest)
+$sql = "
+    SELECT COUNT(*) AS total
+    FROM
+        (SELECT @code := ?) V,
+        tb_post PST
+        JOIN tb_board BRD ON BRD.id = PST.board_id
+    WHERE
+        CASE WHEN @code IS NOT NULL THEN BRD.code = @code ELSE (PST.is_notice = 0 AND PST.is_speaker = 0) END
+        AND BRD.is_display = 1 AND PST.is_delete = 0;
+";
+$total = DB::selectOne($sql, [$code])->total;
+// ... SELECT 목록은 BRD.code, BRD.name AS boardName, BCA.name AS categoryName, PST.* 필드 나열
+$list = $this->getPaginator(
+    DB::select($sql, [$code, $this->getPageOffset($page), $perPage]), $total, $perPage, $page,
+    ($code ? route('board.list', $code) : null)
+);
+return (object)['total' => $total, 'list' => $list];
+```
+
+### SERVICE_CACHE_PATTERN
+```php
+// SOURCE: app/Services/BoardService.php:159-175 (latest)
+$cacheName = sprintf('latest-board-%s-%d-%d', $code, $page, $perPage);
+if (!$latest = Cache::get($cacheName)) {
+    $latest = $this->boardModel->getLatest($code, $page, $perPage);
+    if($latest->total) {
+        $num = listNum($latest->total, $page, $perPage);
+        foreach ($latest->list as $i => $row) {
+            $row->num = $num--;
+            $latest->list[$i] = $this->_filter($row);
+        }
+        $latest->list->filter();
+    }
+    Cache::tags('latest-board')->put($cacheName, $latest, CACHE_EXPIRE_TIME);
+}
+return $latest;
+```
+
+### CONTROLLER_INJECTION
+```php
+// SOURCE: app/Http/Controllers/MainController.php:49-61
+// 사진게시판
+$photo = $this->boardService->latest('photo', 1, DEFAULT_LIST_PER_PAGE);
+// ...
+return view(layout('main'), [ 'popups' => ..., 'photo' => $photo, ... ]);
+```
+
+### BLADE_THUMBNAIL_RENDER
+```blade
+{{-- SOURCE: resources/views/desktop/main.blade.php:146-163 --}}
+@if($photo && $photo->total > 0)
+    <div class="row row-cols-sm-4 row-cols-xl-6">
+    @foreach($photo->list as $row)
+        ...
+        @if($row->thumbnail)
+            <a href="{{ $row->viewURL }}" ... class="ratio ratio-4x3">
+                <img src="{{ $row->thumbnail }}" ... onerror="this.src='{{ NO_IMAGE_NORMAL_SRC }}'"/>
+            </a>
+        @endif
+```
+
+### NAMING_CONVENTION
+- Model 조회: `getLatest`, `getSearch`, `getUserPosts` → 신규는 `getLatestMedia`
+- Service: `latest`, `search`, `userPosts` → 신규는 `latestMedia`
+- SQL 별칭: `PST`(tb_post), `BRD`(tb_board), `BCA`(tb_board_category), `USR`(users)
+- 주석: 한국어 한 줄 doc-comment (`/** 게시판 최근 게시글 */`)
+
+---
+
+## Files to Change
+
+| File | Action | Justification |
+|---|---|---|
+| `app/Models/Board.php` | UPDATE | `getLatestMedia(int $page, int $perPage)` 추가 (getLatest 뒤에) |
+| `app/Services/BoardService.php` | UPDATE | `latestMedia(int $page, int $perPage)` 추가 (latest 뒤에) |
+| `app/Http/Controllers/MainController.php` | UPDATE | `latest('photo',...)` → `latestMedia(...)` 교체 |
+| `resources/views/desktop/main.blade.php` | UPDATE | 제목/전체보기 링크/캡션 라벨 변경 |
+| `resources/views/mobile/main.blade.php` | UPDATE | 동일 변경 (photo-strip 구조 유지) |
+| `app/Console/Commands/RebuildThumbnails.php` | CREATE (선택) | 구(舊) 게시글 썸네일 백필 — Task 6 참조 |
+
+## NOT Building
+- 게시판별 노출 on/off 관리 설정 (board_meta 옵션) — 필요 시 후속
+- YouTube 외 동영상(Vimeo 등) 썸네일 추출 — `getThumbnail` 확장은 별도 건
+- photo section의 페이징/무한스크롤
+- `clearLatest`의 캐시 태그 불일치 수정 (아래 GOTCHA 참조 — 기존 동작 유지)
+
+---
+
+## Step-by-Step Tasks
+
+### Task 1: `Board::getLatestMedia` 추가
+- **ACTION**: `app/Models/Board.php`의 `getLatest`(606행) 바로 뒤에 신규 메서드 추가
+- **IMPLEMENT**:
+```php
+/**
+ * 전체 게시판 최근 미디어(썸네일 보유) 게시글 조회
+ */
+public function getLatestMedia(int $page, int $perPage): object
+{
+    $sql = "
+        SELECT
+            COUNT(*) AS total
+        FROM
+            tb_post PST
+            JOIN tb_board BRD ON BRD.id = PST.board_id
+        WHERE
+            PST.thumbnail IS NOT NULL AND PST.thumbnail != ''
+            AND PST.is_notice = 0 AND PST.is_speaker = 0
+            AND PST.is_secret = 0 AND PST.is_personal = 0
+            AND BRD.is_display = 1 AND PST.is_delete = 0;
+    ";
+
+    $total = DB::selectOne($sql)->total;
+
+    $sql = "
+        SELECT
+            BRD.code, BRD.name AS boardName,
+            BCA.name AS categoryName,
+            PST.id, PST.board_id, PST.board_category_id, PST.user_id, PST.thumbnail, PST.subject, PST.content,
+            PST.sid, PST.username, PST.email, PST.comment_rows, PST.is_reply, PST.is_personal, PST.is_secret,
+            PST.is_notice, PST.is_speaker, PST.is_html, PST.use_comment, PST.receive_email, PST.hit, PST.like, PST.dislike,
+            PST.blame, PST.ip_address, PST.user_agent, PST.device_type, PST.file_rows, PST.image_rows,
+            PST.link_rows, PST.tag_rows, PST.is_delete, PST.created_at
+        FROM
+            tb_post PST
+            INNER JOIN tb_board BRD ON BRD.id = PST.board_id
+            LEFT JOIN tb_board_category BCA ON BCA.id = PST.board_category_id
+            LEFT JOIN users USR ON USR.id = PST.user_id
+        WHERE
+            PST.thumbnail IS NOT NULL AND PST.thumbnail != ''
+            AND PST.is_notice = 0 AND PST.is_speaker = 0
+            AND PST.is_secret = 0 AND PST.is_personal = 0
+            AND BRD.is_display = 1 AND PST.is_delete = 0
+        ORDER BY
+            PST.created_at DESC, PST.id DESC
+        LIMIT ?, ?;
+    ";
+
+    $list = $this->getPaginator(
+        DB::select($sql, [$this->getPageOffset($page), $perPage]), $total, $perPage, $page, null
+    );
+
+    return (object)[
+        'total' => $total,
+        'list' => $list
+    ];
+}
+```
+- **MIRROR**: RAW_SQL_QUERY 패턴 (SELECT 필드 목록·별칭·getPaginator 호출을 getLatest와 동일하게)
+- **IMPORTS**: 추가 불필요 (기존 `DB` 파사드 사용 중)
+- **GOTCHA**: ① 크로스보드 공개 영역이므로 `is_secret = 0 AND is_personal = 0` 필터 **필수** — 기존 getLatest(code=NULL)에는 없어 비밀글이 노출될 수 있는 구조지만, 미디어 섹션은 썸네일이 그대로 보이므로 반드시 막을 것. ② `@code` 세션변수 패턴은 단일 코드 필터용이므로 여기선 불필요 — 제거.
+- **VALIDATE**: `php -l app/Models/Board.php` → No syntax errors
+
+### Task 2: `BoardService::latestMedia` 추가
+- **ACTION**: `app/Services/BoardService.php`의 `latest()`(175행) 바로 뒤에 추가
+- **IMPLEMENT**:
+```php
+/**
+ * 전체 게시판 최근 미디어 게시글
+ */
+public function latestMedia(int $page = 1, int $perPage = DEFAULT_LIST_PER_PAGE)
+{
+    $cacheName = sprintf('latest-media-%d-%d', $page, $perPage);
+    if (!$latest = Cache::get($cacheName)) {
+        $latest = $this->boardModel->getLatestMedia($page, $perPage);
+        if($latest->total) {
+            $num = listNum($latest->total, $page, $perPage);
+            foreach ($latest->list as $i => $row) {
+                $row->num = $num--;
+                $latest->list[$i] = $this->_filter($row);
+            }
+            $latest->list->filter();
+        }
+        Cache::tags('latest-board')->put($cacheName, $latest, CACHE_EXPIRE_TIME);
+    }
+    return $latest;
+}
+```
+- **MIRROR**: SERVICE_CACHE_PATTERN (latest()와 동일 구조, 캐시명만 `latest-media-…`)
+- **IMPORTS**: 추가 불필요
+- **GOTCHA**: ① `_filter()`가 null 반환 가능(공지 제외 설정 등) → `list->filter()` 호출 유지. ② 기존 `clearLatest()`는 태그 `latest-board-{code}`를 flush하지만 `latest()`는 태그 `latest-board`로 저장하는 **기존 불일치**가 있음 — 신규도 동일 태그(`latest-board`)를 사용해 기존 동작(CACHE_EXPIRE_TIME 만료 의존)과 일관 유지. 글 등록 직후 즉시 반영 안 되는 것은 기존 photo section과 동일한 특성.
+- **VALIDATE**: `php -l app/Services/BoardService.php`
+
+### Task 3: `MainController::index` 교체
+- **ACTION**: `app/Http/Controllers/MainController.php:49-50` 수정
+- **IMPLEMENT**:
+```php
+// 전체 게시판 미디어(사진/동영상)
+$photo = $this->boardService->latestMedia(1, DEFAULT_LIST_PER_PAGE);
+```
+- **MIRROR**: CONTROLLER_INJECTION — 뷰 변수명 `photo` 유지(뷰 diff 최소화)
+- **GOTCHA**: 변수명을 바꾸면 desktop/mobile 두 뷰를 모두 수정해야 하므로 유지한다.
+- **VALIDATE**: `php -l app/Http/Controllers/MainController.php`
+
+### Task 4: 데스크톱 뷰 수정
+- **ACTION**: `resources/views/desktop/main.blade.php:136-163`
+- **IMPLEMENT**: ① 제목 `일상/풍경/사진` → `포토/미디어`, ② 전체보기 `route('board.list', 'photo')` → `route('recently')`, ③ 캡션 라벨을 게시판 출처로:
+```blade
+@if($row->categoryName)
+    <label>
+        <a href="{{ $row->listURL }}?category={{ $row->board_category_id }}" rel="search">[{{ $row->categoryName }}]</a>
+    </label>
+@else
+    <label>
+        <a href="{{ $row->listURL }}" rel="search">[{{ $row->boardName }}]</a>
+    </label>
+@endif
+```
+- **MIRROR**: BLADE_THUMBNAIL_RENDER — `@if($row->thumbnail)` 가드·`onerror` 폴백 유지
+- **GOTCHA**: `getLatestMedia`가 썸네일 보유 글만 반환하므로 `@if($row->thumbnail)` 가드는 사실상 항상 참이지만, `_filter` 이후 안전장치로 유지.
+- **VALIDATE**: `php artisan view:clear && php artisan view:cache` 오류 없음
+
+### Task 5: 모바일 뷰 수정
+- **ACTION**: `resources/views/mobile/main.blade.php:136-160` — Task 4와 동일 변경 (photo-strip 가로 스크롤 구조는 유지)
+- **MIRROR**: Task 4
+- **VALIDATE**: `php artisan view:clear && php artisan view:cache`
+
+### Task 6 (선택): 구 게시글 썸네일 백필 커맨드
+- **ACTION**: 썸네일 저장 기능 도입 이전의 옛 글은 `thumbnail`이 NULL이라 미디어 섹션에 안 나온다. 필요 시 `php artisan make:command RebuildThumbnails`로 백필.
+- **IMPLEMENT**: `tb_post`에서 `thumbnail IS NULL AND (image_rows > 0 OR content LIKE '%<img%' OR content LIKE '%iframe%')` 대상 조회 → 각 행에 `CommonTrait::getThumbnail($row->content)` 적용 → UPDATE. 청크(1000행) 단위 처리.
+- **GOTCHA**: `getThumbnail`은 YouTube만 동영상 썸네일 지원. 실행 전 `SELECT COUNT(*)`로 대상 규모 확인.
+- **VALIDATE**: dry-run 옵션(`--dry`)으로 대상 수만 출력 → 실제 실행 → 메인 확인
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+프로젝트에 기존 테스트 스위트가 확인되지 않음 — 수동 검증 중심. (테스트 도입 시 `Board::getLatestMedia` 쿼리 결과의 비밀글 미포함을 최우선 검증)
+
+| Test | Input | Expected Output | Edge Case? |
+|---|---|---|---|
+| 미디어 섹션 조회 | 이미지 글 3개(각기 다른 게시판) | 3개 모두 노출 | |
+| YouTube 임베드 글 | iframe만 있는 글 | `img.youtube.com/vi/{key}/mqdefault.jpg` 썸네일 노출 | |
+| 비밀글 | is_secret=1 + 이미지 | 미노출 | ✅ |
+| 1:1 게시판 글 | is_personal=1 | 미노출 | ✅ |
+| 텍스트만 있는 글 | 이미지/iframe 없음 | 미노출 (thumbnail NULL) | ✅ |
+| 미노출 게시판 | is_display=0 | 미노출 | ✅ |
+
+### Edge Cases Checklist
+- [ ] 썸네일 보유 글이 0건일 때 빈 섹션 처리
+- [ ] 삭제된 글(is_delete=1) 제외
+- [ ] 캐시 적중 시 stale 데이터 (CACHE_EXPIRE_TIME 내) — 기존과 동일 특성 확인
+- [ ] 외부 이미지 URL 깨짐 → `onerror` NO_IMAGE 폴백
+
+---
+
+## Validation Commands
+
+### Static Analysis
+```bash
+php -l app/Models/Board.php && php -l app/Services/BoardService.php && php -l app/Http/Controllers/MainController.php
+```
+EXPECT: No syntax errors
+
+### View Compile
+```bash
+php artisan view:clear && php artisan view:cache
+```
+EXPECT: 컴파일 오류 없음
+
+### Cache Reset (변경 반영 확인용)
+```bash
+php artisan cache:clear
+```
+EXPECT: 캐시 초기화 후 메인 접속 시 신규 쿼리 실행
+
+### Manual Validation
+- [ ] 메인 접속 → photo section에 photo 외 게시판 글(이미지 포함) 노출 확인
+- [ ] YouTube 임베드만 있는 글 작성 → 메인에 YouTube 썸네일 노출 확인
+- [ ] 비밀글로 이미지 글 작성 → 메인 미노출 확인
+- [ ] 모바일 레이아웃(가로 스크롤 strip) 정상 확인
+- [ ] 전체보기 → /recently 이동 확인
+
+---
+
+## Acceptance Criteria
+- [ ] 모든 게시판의 이미지/YouTube 글이 메인 photo section에 노출
+- [ ] 비밀글·1:1글·비노출 게시판·삭제글 미노출
+- [ ] 데스크톱/모바일 모두 정상 렌더
+- [ ] 문법 오류·뷰 컴파일 오류 없음
+
+## Completion Checklist
+- [ ] 신규 코드가 getLatest/latest 패턴과 구분 불가능한 스타일
+- [ ] SQL 별칭·SELECT 필드 목록 기존과 동일
+- [ ] 캐시 패턴·만료 정책 기존과 동일
+- [ ] 하드코딩 없음 (DEFAULT_LIST_PER_PAGE, CACHE_EXPIRE_TIME 상수 사용)
+- [ ] 스코프 외 작업 없음
+
+## Risks
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| 옛 글 thumbnail NULL로 섹션이 허전함 | 중 | 낮 | Task 6 백필 커맨드 |
+| 비밀글 썸네일 노출 | 낮 (필터 포함) | 높 | is_secret/is_personal 필터 + 수동 검증 항목 |
+| thumbnail 컬럼 무인덱스로 풀스캔 | 중 | 중 | 데이터 증가 시 인덱스 검토; 현재 규모에선 무시 가능 + 결과 캐시됨 |
+| 캐시로 인한 지연 반영 | 확실 | 낮 | 기존 photo section과 동일 특성 — 허용 |
+
+## Notes
+- `getThumbnail`(CommonTrait:152)은 이미지가 하나도 없을 때만 YouTube 썸네일을 시도한다. 이미지+영상 혼재 글은 첫 이미지가 대표가 된다 — 의도된 동작으로 간주.
+- `photo` 게시판 전용 섹션을 유지하고 싶다면 MainController에서 `latest('photo')`를 병행 주입하는 것도 가능하나, 본 플랜은 **교체**를 기준으로 한다.

+ 62 - 0
.claude/PRPs/reports/main-photo-section-all-boards-report.md

@@ -0,0 +1,62 @@
+# Implementation Report: 메인 Photo Section — 전 게시판 미디어 노출
+
+## Summary
+메인페이지 photo section을 photo 게시판 전용에서 **전 게시판의 썸네일(이미지/YouTube 임베드) 보유 글**로 확장. 신규 조회 메서드 2개 추가 + 컨트롤러 교체 + 뷰 2개 수정.
+
+## Assessment vs Reality
+
+| Metric | Predicted (Plan) | Actual |
+|---|---|---|
+| Complexity | Small–Medium | Small–Medium |
+| Confidence | 9/10 | 적중 — 플랜 그대로 구현 |
+| Files Changed | 5 (+1 선택) | 5 (선택 Task 6 미실행) |
+
+## Tasks Completed
+
+| # | Task | Status | Notes |
+|---|---|---|---|
+| 1 | `Board::getLatestMedia` 추가 | ✅ Complete | is_secret/is_personal 필터 포함 |
+| 2 | `BoardService::latestMedia` 추가 | ✅ Complete | 캐시 `latest-media-{p}-{pp}`, 태그 latest-board |
+| 3 | `MainController::index` 교체 | ✅ Complete | 뷰 변수명 `photo` 유지 |
+| 4 | 데스크톱 뷰 수정 | ✅ Complete | 제목·전체보기·boardName 라벨 |
+| 5 | 모바일 뷰 수정 | ✅ Complete | 동일 |
+| 6 | 썸네일 백필 커맨드 (선택) | ⏭ Skipped | 필요 시 후속 — 옛 글 thumbnail NULL 대상 |
+
+## Validation Results
+
+| Level | Status | Notes |
+|---|---|---|
+| Static Analysis | ✅ Pass | `php -l` 3개 파일 (dockerfiles-php-fpm 컨테이너) — 오류 0 |
+| Blade Compile | ✅ Pass | BladeCompiler 단독 컴파일 + php -l — 두 뷰 OK |
+| Unit Tests | N/A | 프로젝트에 테스트 스위트 없음 (플랜 명시) |
+| Build | N/A | PHP — 빌드 단계 없음 |
+| Integration | ⏸ Manual | 로컬 artisan 부트스트랩이 Redis/DB 연결 필요로 컨테이너에서 실패 → 수동 검증 항목으로 이관 |
+
+## Files Changed
+
+| File | Action | 내용 |
+|---|---|---|
+| `app/Models/Board.php` | UPDATED | `getLatestMedia()` 추가 (+52줄) |
+| `app/Services/BoardService.php` | UPDATED | `latestMedia()` 추가 (+21줄) |
+| `app/Http/Controllers/MainController.php` | UPDATED | latest('photo') → latestMedia() (2줄) |
+| `resources/views/desktop/main.blade.php` | UPDATED | 제목/링크/캡션 @else (+8줄) |
+| `resources/views/mobile/main.blade.php` | UPDATED | 동일 (+8줄) |
+
+## Deviations from Plan
+None — 플랜 그대로 구현.
+
+## Issues Encountered
+- 로컬/컨테이너 모두 full artisan 부트스트랩 불가(AppServiceProvider가 Redis+DB 요구) → `view:cache` 대신 BladeCompiler 단독 컴파일 검증으로 대체 (동등한 문법 보증).
+
+## Tests Written
+없음 — 테스트 스위트 부재. 수동 검증 체크리스트로 대체:
+- [ ] 캐시 초기화(`php artisan cache:clear` 또는 CACHE_EXPIRE_TIME 대기) 후 메인 접속
+- [ ] photo 외 게시판의 이미지 글이 섹션에 노출
+- [ ] YouTube 임베드만 있는 글 → YouTube 썸네일 노출
+- [ ] 비밀글(is_secret=1)·1:1글(is_personal=1) 미노출
+- [ ] 모바일 photo-strip 정상, 전체보기 → /recently
+
+## Next Steps
+- [ ] 수동 검증 (위 체크리스트)
+- [ ] 필요 시 Task 6 백필: 옛 글 `thumbnail IS NULL AND image_rows > 0` 대상
+- [ ] `/prp-commit` 으로 커밋