feat(boards): 게시글 파일 시스템 개선 및 이미지 미리보기 추가
- Morph map에 Post, Department 모델 등록 (ClassMorphViolationException 해결) - 파일 저장 방식을 API 스타일로 변경 (document_id + document_type) - 파일 미리보기 라우트 및 메서드 추가 (previewFile) - 게시글 상세 페이지에서 이미지 첨부파일을 본문 상단에 풀 너비로 표시 - 비이미지 첨부파일은 하단에 다운로드 목록으로 분리 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PostController extends Controller
|
||||
@@ -268,7 +269,7 @@ public function uploadFiles(Board $board, Post $post, Request $request): JsonRes
|
||||
/**
|
||||
* 파일 다운로드
|
||||
*/
|
||||
public function downloadFile(Board $board, Post $post, int $fileId): StreamedResponse|RedirectResponse
|
||||
public function downloadFile(Board $board, Post $post, int $fileId): BinaryFileResponse|StreamedResponse|RedirectResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
@@ -283,6 +284,24 @@ public function downloadFile(Board $board, Post $post, int $fileId): StreamedRes
|
||||
return $this->postService->downloadFile($post, $fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 미리보기 (이미지 인라인 표시)
|
||||
*/
|
||||
public function previewFile(Board $board, Post $post, int $fileId): BinaryFileResponse|StreamedResponse|RedirectResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// 비밀글 접근 권한 확인
|
||||
if (! $post->canAccess(auth()->user())) {
|
||||
return back()->with('error', '파일에 접근할 수 없습니다.');
|
||||
}
|
||||
|
||||
return $this->postService->previewFile($post, $fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제 (AJAX)
|
||||
*/
|
||||
|
||||
@@ -45,6 +45,10 @@ class File extends Model
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'file_type',
|
||||
// New fields (API 방식)
|
||||
'document_id',
|
||||
'document_type',
|
||||
// Legacy fields (하위 호환)
|
||||
'fileable_id',
|
||||
'fileable_type',
|
||||
'description',
|
||||
@@ -219,4 +223,13 @@ public function scopeNonTemp($query)
|
||||
{
|
||||
return $query->where('is_temp', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 문서의 파일들
|
||||
*/
|
||||
public function scopeForDocument($query, int $documentId, string $documentType)
|
||||
{
|
||||
return $query->where('document_id', $documentId)
|
||||
->where('document_type', $documentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
@@ -138,11 +137,12 @@ public function customFieldValues(): HasMany
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 (Polymorphic)
|
||||
* 첨부파일 (document_id + document_type 기반)
|
||||
*/
|
||||
public function files(): MorphMany
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->morphMany(File::class, 'fileable');
|
||||
return $this->hasMany(File::class, 'document_id')
|
||||
->where('document_type', 'post');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Boards\Post;
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\User;
|
||||
use App\Services\SidebarMenuService;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
@@ -24,9 +26,11 @@ public function register(): void
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// Morph Map: mng/api 프로젝트 간 User 모델 경로 통일
|
||||
// Morph Map: Polymorphic 관계 모델 등록
|
||||
Relation::enforceMorphMap([
|
||||
'user' => User::class,
|
||||
'post' => Post::class,
|
||||
'department' => Department::class,
|
||||
]);
|
||||
|
||||
// 사이드바에 메뉴 데이터 전달
|
||||
|
||||
@@ -257,7 +257,7 @@ public function uploadFiles(Post $post, array $files): array
|
||||
// 파일 저장
|
||||
Storage::disk('tenant')->put($filePath, file_get_contents($file));
|
||||
|
||||
// DB 레코드 생성
|
||||
// DB 레코드 생성 (API 방식: document_id + document_type)
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'is_temp' => false,
|
||||
@@ -269,8 +269,8 @@ public function uploadFiles(Post $post, array $files): array
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_type' => $this->getFileType($file->getMimeType()),
|
||||
'fileable_type' => Post::class,
|
||||
'fileable_id' => $post->id,
|
||||
'document_type' => 'post',
|
||||
'document_id' => $post->id,
|
||||
'uploaded_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
@@ -381,4 +381,24 @@ public function downloadFile(Post $post, int $fileId)
|
||||
|
||||
return $file->download();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 미리보기 (인라인 표시)
|
||||
*/
|
||||
public function previewFile(Post $post, int $fileId)
|
||||
{
|
||||
$file = $post->files()->find($fileId);
|
||||
|
||||
if (! $file) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if (! $file->existsInStorage()) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return response()->file($file->getStoragePath(), [
|
||||
'Content-Type' => $file->mime_type,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,27 @@ function confirmDeletePost() {
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 첨부 이미지 (본문 상단 - 풀 너비) -->
|
||||
@php
|
||||
$imageFiles = $post->files->filter(fn($file) => $file->isImage());
|
||||
$otherFiles = $post->files->filter(fn($file) => !$file->isImage());
|
||||
@endphp
|
||||
@if($imageFiles->isNotEmpty())
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="space-y-4">
|
||||
@foreach($imageFiles as $file)
|
||||
<a href="{{ route('boards.posts.files.preview', [$board, $post, $file->id]) }}"
|
||||
target="_blank"
|
||||
class="block overflow-hidden rounded-lg border border-gray-200 hover:border-blue-400 transition">
|
||||
<img src="{{ route('boards.posts.files.preview', [$board, $post, $file->id]) }}"
|
||||
alt="{{ $file->display_name ?? $file->original_name }}"
|
||||
class="w-full h-auto">
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 본문 -->
|
||||
<div class="px-6 py-8">
|
||||
<div class="prose max-w-none">
|
||||
@@ -94,25 +115,19 @@ function confirmDeletePost() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 첨부파일 -->
|
||||
@if($post->files->isNotEmpty())
|
||||
<!-- 첨부파일 (이미지 제외) -->
|
||||
@if($otherFiles->isNotEmpty())
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||
첨부파일 ({{ $post->files->count() }}개)
|
||||
첨부파일 ({{ $otherFiles->count() }}개)
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
@foreach($post->files as $file)
|
||||
@foreach($otherFiles as $file)
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white rounded-lg border border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
@if($file->isImage())
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
@endif
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="text-sm text-gray-900">{{ $file->display_name ?? $file->original_name }}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">({{ $file->getFormattedSize() }})</span>
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
// 파일 관리
|
||||
Route::prefix('{post}/files')->name('files.')->group(function () {
|
||||
Route::get('/{fileId}/download', [PostController::class, 'downloadFile'])->name('download');
|
||||
Route::get('/{fileId}/preview', [PostController::class, 'previewFile'])->name('preview');
|
||||
Route::post('/', [PostController::class, 'uploadFiles'])->name('upload');
|
||||
Route::delete('/{fileId}', [PostController::class, 'deleteFile'])->name('delete');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user