feat: 게시글 파일 첨부 기능 구현

- File 모델 추가 (Polymorphic 관계)
- Post 모델에 files() MorphMany 관계 추가
- PostService 파일 업로드/삭제/다운로드 메서드 추가
- PostController 파일 관련 액션 추가
- 게시글 작성/수정 폼에 드래그앤드롭 파일 업로드 UI
- 게시글 상세에 첨부파일 목록 표시
- tenant 디스크 설정 (공유 스토리지)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-02 00:53:25 +09:00
parent 7c7c04f8dc
commit 8948aa86d0
15 changed files with 2394 additions and 4 deletions

View File

@@ -0,0 +1,309 @@
<?php
namespace App\Http\Controllers;
use App\Models\Boards\Board;
use App\Models\Boards\Post;
use App\Services\PostService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PostController extends Controller
{
public function __construct(
private readonly PostService $postService
) {}
/**
* 게시글 목록
*/
public function index(Board $board, Request $request): View
{
$filters = $request->only(['search', 'is_notice']);
$posts = $this->postService->getPosts($board->id, $filters, 15);
$notices = $this->postService->getNotices($board->id, 5);
$stats = $this->postService->getBoardPostStats($board->id);
return view('posts.index', compact('board', 'posts', 'notices', 'stats', 'filters'));
}
/**
* 게시글 작성 폼
*/
public function create(Board $board): View
{
$fields = $board->fields;
return view('posts.create', compact('board', 'fields'));
}
/**
* 게시글 저장
*/
public function store(Board $board, Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'nullable|string',
'is_notice' => 'nullable|boolean',
'is_secret' => 'nullable|boolean',
'files' => 'nullable|array',
'files.*' => 'file|max:'.($board->max_file_size),
]);
// 커스텀 필드 값
$customFields = $request->input('custom_fields', []);
// 커스텀 필드 유효성 검사
foreach ($board->fields as $field) {
if ($field->is_required && empty($customFields[$field->field_key])) {
return back()
->withInput()
->withErrors([$field->field_key => "{$field->name}은(는) 필수입니다."]);
}
}
$validated['is_notice'] = $request->boolean('is_notice');
$validated['is_secret'] = $request->boolean('is_secret');
$post = $this->postService->createPost($board, $validated, $customFields);
// 파일 업로드 처리
if ($request->hasFile('files') && $board->allow_files) {
try {
$this->postService->uploadFiles($post, $request->file('files'));
} catch (\Exception $e) {
// 파일 업로드 실패해도 게시글은 저장됨 - 경고 메시지만 표시
return redirect()
->route('boards.posts.show', [$board, $post])
->with('warning', '게시글이 작성되었으나 일부 파일 업로드에 실패했습니다: '.$e->getMessage());
}
}
return redirect()
->route('boards.posts.show', [$board, $post])
->with('success', '게시글이 작성되었습니다.');
}
/**
* 게시글 상세
*/
public function show(Board $board, Post $post): View|RedirectResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
abort(404);
}
// 비밀글 접근 권한 확인
if (! $post->canAccess(auth()->user())) {
return back()->with('error', '비밀글은 작성자만 볼 수 있습니다.');
}
// 조회수 증가
$this->postService->incrementViews($post);
// 이전/다음 글
$adjacent = $this->postService->getAdjacentPosts($post);
// 커스텀 필드 값
$customFieldValues = $post->getCustomFieldsArray();
return view('posts.show', compact('board', 'post', 'adjacent', 'customFieldValues'));
}
/**
* 게시글 수정 폼
*/
public function edit(Board $board, Post $post): View|RedirectResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
abort(404);
}
// 수정 권한 확인 (작성자 또는 관리자)
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
return back()->with('error', '수정 권한이 없습니다.');
}
$fields = $board->fields;
$customFieldValues = $post->getCustomFieldsArray();
return view('posts.edit', compact('board', 'post', 'fields', 'customFieldValues'));
}
/**
* 게시글 수정 저장
*/
public function update(Board $board, Post $post, Request $request): RedirectResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
abort(404);
}
// 수정 권한 확인
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
return back()->with('error', '수정 권한이 없습니다.');
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'nullable|string',
'is_notice' => 'nullable|boolean',
'is_secret' => 'nullable|boolean',
'files' => 'nullable|array',
'files.*' => 'file|max:'.($board->max_file_size),
]);
// 커스텀 필드 값
$customFields = $request->input('custom_fields', []);
// 커스텀 필드 유효성 검사
foreach ($board->fields as $field) {
if ($field->is_required && empty($customFields[$field->field_key])) {
return back()
->withInput()
->withErrors([$field->field_key => "{$field->name}은(는) 필수입니다."]);
}
}
$validated['is_notice'] = $request->boolean('is_notice');
$validated['is_secret'] = $request->boolean('is_secret');
$this->postService->updatePost($post, $validated, $customFields);
// 파일 업로드 처리
if ($request->hasFile('files') && $board->allow_files) {
try {
$this->postService->uploadFiles($post, $request->file('files'));
} catch (\Exception $e) {
return redirect()
->route('boards.posts.show', [$board, $post])
->with('warning', '게시글이 수정되었으나 일부 파일 업로드에 실패했습니다: '.$e->getMessage());
}
}
return redirect()
->route('boards.posts.show', [$board, $post])
->with('success', '게시글이 수정되었습니다.');
}
/**
* 게시글 삭제
*/
public function destroy(Board $board, Post $post): RedirectResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
abort(404);
}
// 삭제 권한 확인
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
return back()->with('error', '삭제 권한이 없습니다.');
}
// 첨부 파일 삭제
$this->postService->deleteAllFiles($post);
$this->postService->deletePost($post);
return redirect()
->route('boards.posts.index', $board)
->with('success', '게시글이 삭제되었습니다.');
}
// =========================================================================
// File Management
// =========================================================================
/**
* 파일 업로드 (AJAX)
*/
public function uploadFiles(Board $board, Post $post, Request $request): JsonResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
return response()->json(['success' => false, 'message' => '게시글을 찾을 수 없습니다.'], 404);
}
// 수정 권한 확인
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
}
// 파일 첨부 허용 여부
if (! $board->allow_files) {
return response()->json(['success' => false, 'message' => '이 게시판은 파일 첨부가 허용되지 않습니다.'], 400);
}
$request->validate([
'files' => 'required|array',
'files.*' => 'file|max:'.($board->max_file_size),
]);
try {
$uploadedFiles = $this->postService->uploadFiles($post, $request->file('files'));
return response()->json([
'success' => true,
'message' => count($uploadedFiles).'개의 파일이 업로드되었습니다.',
'files' => collect($uploadedFiles)->map(fn ($file) => [
'id' => $file->id,
'name' => $file->display_name ?? $file->original_name,
'size' => $file->getFormattedSize(),
'type' => $file->file_type,
]),
]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 400);
}
}
/**
* 파일 다운로드
*/
public function downloadFile(Board $board, Post $post, int $fileId): StreamedResponse|RedirectResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
abort(404);
}
// 비밀글 접근 권한 확인
if (! $post->canAccess(auth()->user())) {
return back()->with('error', '파일에 접근할 수 없습니다.');
}
return $this->postService->downloadFile($post, $fileId);
}
/**
* 파일 삭제 (AJAX)
*/
public function deleteFile(Board $board, Post $post, int $fileId): JsonResponse
{
// 게시판 일치 확인
if ($post->board_id !== $board->id) {
return response()->json(['success' => false, 'message' => '게시글을 찾을 수 없습니다.'], 404);
}
// 삭제 권한 확인 (작성자 또는 관리자)
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
}
try {
$this->postService->deleteFile($post, $fileId);
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 400);
}
}
}

View File

@@ -104,6 +104,11 @@ public function fields(): HasMany
->orderBy('sort_order');
}
public function posts(): HasMany
{
return $this->hasMany(Post::class, 'board_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');

222
app/Models/Boards/File.php Normal file
View File

@@ -0,0 +1,222 @@
<?php
namespace App\Models\Boards;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
/**
* 파일 모델 (Polymorphic)
*
* @property int $id
* @property int|null $tenant_id
* @property int|null $folder_id
* @property bool $is_temp
* @property string $file_path
* @property string|null $display_name
* @property string|null $stored_name
* @property string|null $original_name
* @property int $file_size
* @property string|null $mime_type
* @property string|null $file_type
* @property int|null $fileable_id
* @property string|null $fileable_type
* @property int|null $uploaded_by
*/
class File extends Model
{
use SoftDeletes;
protected $table = 'files';
protected $fillable = [
'tenant_id',
'folder_id',
'is_temp',
'file_path',
'display_name',
'stored_name',
'original_name',
'file_name',
'file_size',
'mime_type',
'file_type',
'fileable_id',
'fileable_type',
'description',
'uploaded_by',
'deleted_by',
'created_by',
'updated_by',
];
protected $casts = [
'is_temp' => 'boolean',
'file_size' => 'integer',
];
// =========================================================================
// Relationships
// =========================================================================
/**
* Polymorphic 관계 - 연결된 모델 (Post, Product 등)
*/
public function fileable(): MorphTo
{
return $this->morphTo();
}
/**
* 업로더
*/
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by');
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 스토리지 전체 경로
*/
public function getStoragePath(): string
{
return Storage::disk('tenant')->path($this->file_path);
}
/**
* 파일 존재 여부
*/
public function existsInStorage(): bool
{
return Storage::disk('tenant')->exists($this->file_path);
}
/**
* 다운로드 응답
*/
public function download()
{
if (! $this->existsInStorage()) {
abort(404, '파일을 찾을 수 없습니다.');
}
$fileName = $this->display_name ?? $this->original_name ?? $this->file_name;
return response()->download($this->getStoragePath(), $fileName);
}
/**
* 파일 URL (public 접근용)
*/
public function getUrl(): ?string
{
if (! $this->existsInStorage()) {
return null;
}
return Storage::disk('tenant')->url($this->file_path);
}
/**
* 파일 크기 포맷팅
*/
public function getFormattedSize(): string
{
$bytes = $this->file_size;
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2).' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2).' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2).' KB';
}
return $bytes.' bytes';
}
/**
* 파일 확장자
*/
public function getExtension(): string
{
$fileName = $this->stored_name ?? $this->file_name ?? $this->original_name;
return pathinfo($fileName, PATHINFO_EXTENSION);
}
/**
* 이미지 여부
*/
public function isImage(): bool
{
return str_starts_with($this->mime_type ?? '', 'image/');
}
/**
* Soft Delete with user tracking
*/
public function softDeleteFile(int $userId): void
{
$this->deleted_by = $userId;
$this->save();
$this->delete();
}
/**
* 완전 삭제 (물리 파일 포함)
*/
public function permanentDelete(): void
{
if ($this->existsInStorage()) {
Storage::disk('tenant')->delete($this->file_path);
}
$this->forceDelete();
}
// =========================================================================
// Scopes
// =========================================================================
/**
* 특정 모델의 파일들
*/
public function scopeForModel($query, string $type, int $id)
{
return $query->where('fileable_type', $type)
->where('fileable_id', $id);
}
/**
* 임시 파일만
*/
public function scopeTemp($query)
{
return $query->where('is_temp', true);
}
/**
* 임시 파일 제외
*/
public function scopeNonTemp($query)
{
return $query->where('is_temp', false);
}
}

211
app/Models/Boards/Post.php Normal file
View File

@@ -0,0 +1,211 @@
<?php
namespace App\Models\Boards;
use App\Models\Tenants\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
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;
/**
* 게시글 모델
*
* @property int $id
* @property int|null $tenant_id
* @property int $board_id
* @property int $user_id
* @property string $title
* @property string|null $content
* @property string $editor_type
* @property string|null $ip_address
* @property bool $is_notice
* @property bool $is_secret
* @property int $views
* @property string $status
*/
class Post extends Model
{
use SoftDeletes;
protected $table = 'posts';
protected $fillable = [
'tenant_id',
'board_id',
'user_id',
'title',
'content',
'editor_type',
'ip_address',
'is_notice',
'is_secret',
'views',
'status',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'is_notice' => 'boolean',
'is_secret' => 'boolean',
'views' => 'integer',
];
protected $attributes = [
'is_notice' => false,
'is_secret' => false,
'views' => 0,
'status' => 'published',
'editor_type' => 'wysiwyg',
];
// =========================================================================
// Scopes
// =========================================================================
/**
* 특정 게시판의 글
*/
public function scopeOfBoard(Builder $query, int $boardId): Builder
{
return $query->where('board_id', $boardId);
}
/**
* 공지사항만
*/
public function scopeNotices(Builder $query): Builder
{
return $query->where('is_notice', true);
}
/**
* 일반 글만 (공지 제외)
*/
public function scopeRegular(Builder $query): Builder
{
return $query->where('is_notice', false);
}
/**
* 게시된 글만
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published');
}
/**
* 비밀글이 아닌 것만
*/
public function scopePublic(Builder $query): Builder
{
return $query->where('is_secret', false);
}
// =========================================================================
// Relationships
// =========================================================================
public function board(): BelongsTo
{
return $this->belongsTo(Board::class, 'board_id');
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_id');
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function customFieldValues(): HasMany
{
return $this->hasMany(PostCustomFieldValue::class, 'post_id');
}
/**
* 첨부파일 (Polymorphic)
*/
public function files(): MorphMany
{
return $this->morphMany(File::class, 'fileable');
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 조회수 증가
*/
public function incrementViews(): void
{
$this->increment('views');
}
/**
* 커스텀 필드 값 가져오기
*/
public function getCustomFieldValue(string $fieldKey): ?string
{
$fieldValue = $this->customFieldValues()
->whereHas('field', fn ($q) => $q->where('field_key', $fieldKey))
->first();
return $fieldValue?->value;
}
/**
* 커스텀 필드 값들을 배열로 가져오기
*/
public function getCustomFieldsArray(): array
{
return $this->customFieldValues()
->with('field')
->get()
->mapWithKeys(fn ($v) => [$v->field->field_key => $v->value])
->toArray();
}
/**
* 비밀글 접근 가능 여부
*/
public function canAccess(?User $user): bool
{
// 비밀글이 아니면 모두 접근 가능
if (! $this->is_secret) {
return true;
}
// 비로그인이면 접근 불가
if (! $user) {
return false;
}
// 작성자 본인이면 접근 가능
if ($this->user_id === $user->id) {
return true;
}
// 관리자면 접근 가능
if ($user->hasRole(['admin', 'super-admin'])) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\Boards;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 게시글 커스텀 필드 값 (EAV 값 저장)
*
* @property int $id
* @property int $post_id
* @property int $field_id
* @property string|null $value
*/
class PostCustomFieldValue extends Model
{
protected $table = 'post_custom_field_values';
protected $fillable = [
'post_id',
'field_id',
'value',
'created_by',
'updated_by',
];
// =========================================================================
// Relationships
// =========================================================================
public function post(): BelongsTo
{
return $this->belongsTo(Post::class, 'post_id');
}
public function field(): BelongsTo
{
return $this->belongsTo(BoardSetting::class, 'field_id');
}
}

View File

@@ -174,4 +174,43 @@ public function canAccessMng(): bool
{
return $this->belongsToHQ() && $this->is_active;
}
/**
* 특정 역할 보유 여부 확인
*
* @param string|array $roles 역할명 또는 역할명 배열
*/
public function hasRole(string|array $roles): bool
{
// 슈퍼관리자는 모든 역할 보유
if ($this->is_super_admin) {
return true;
}
$roles = is_array($roles) ? $roles : [$roles];
// 현재 테넌트 기준 역할 확인
$currentTenant = $this->currentTenant();
if (! $currentTenant) {
return false;
}
$userRoles = $this->getRolesForTenant($currentTenant->id);
foreach ($roles as $roleName) {
if ($userRoles->contains('name', $roleName)) {
return true;
}
}
return false;
}
/**
* 관리자 역할 보유 여부 (admin 또는 super-admin)
*/
public function isAdmin(): bool
{
return $this->is_super_admin || $this->hasRole(['admin', 'super-admin']);
}
}

View File

@@ -0,0 +1,384 @@
<?php
namespace App\Services;
use App\Models\Boards\Board;
use App\Models\Boards\File;
use App\Models\Boards\Post;
use App\Models\Boards\PostCustomFieldValue;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PostService
{
/**
* 게시글 목록 조회 (페이지네이션)
*/
public function getPosts(int $boardId, array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = Post::query()
->ofBoard($boardId)
->with(['author', 'board'])
->published();
// 검색
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%");
});
}
// 공지사항 필터
if (isset($filters['is_notice'])) {
$query->where('is_notice', $filters['is_notice']);
}
// 정렬: 공지사항 먼저, 그 다음 최신순
$query->orderByDesc('is_notice')
->orderByDesc('created_at');
return $query->paginate($perPage);
}
/**
* 공지사항 목록 (상단 고정용)
*/
public function getNotices(int $boardId, int $limit = 5): Collection
{
return Post::query()
->ofBoard($boardId)
->published()
->notices()
->with('author')
->orderByDesc('created_at')
->limit($limit)
->get();
}
/**
* 게시글 상세 조회
*/
public function getPostById(int $id, bool $withTrashed = false): ?Post
{
$query = Post::query()
->with(['board.fields', 'author', 'customFieldValues.field']);
if ($withTrashed) {
$query->withTrashed();
}
return $query->find($id);
}
/**
* 게시글 생성
*/
public function createPost(Board $board, array $data, array $customFields = []): Post
{
return DB::transaction(function () use ($board, $data, $customFields) {
// 기본 데이터 설정
$data['board_id'] = $board->id;
// 시스템 게시판(tenant_id=null)인 경우 사용자의 현재 테넌트 사용
$data['tenant_id'] = $board->tenant_id ?? auth()->user()->currentTenant()?->id;
$data['user_id'] = auth()->id();
$data['created_by'] = auth()->id();
$data['editor_type'] = $board->editor_type;
$data['ip_address'] = request()->ip();
$post = Post::create($data);
// 커스텀 필드 저장
$this->saveCustomFields($post, $board, $customFields);
return $post->load(['board', 'author', 'customFieldValues.field']);
});
}
/**
* 게시글 수정
*/
public function updatePost(Post $post, array $data, array $customFields = []): Post
{
return DB::transaction(function () use ($post, $data, $customFields) {
$data['updated_by'] = auth()->id();
$post->update($data);
// 커스텀 필드 업데이트
$this->saveCustomFields($post, $post->board, $customFields);
return $post->fresh(['board', 'author', 'customFieldValues.field']);
});
}
/**
* 게시글 삭제 (Soft Delete)
*/
public function deletePost(Post $post): bool
{
$post->deleted_by = auth()->id();
$post->save();
return $post->delete();
}
/**
* 게시글 영구 삭제
*/
public function forceDeletePost(Post $post): bool
{
// 커스텀 필드 값 삭제
$post->customFieldValues()->delete();
return $post->forceDelete();
}
/**
* 조회수 증가
*/
public function incrementViews(Post $post): void
{
$post->incrementViews();
}
/**
* 커스텀 필드 저장
*/
private function saveCustomFields(Post $post, Board $board, array $customFields): void
{
// 기존 값 삭제 후 새로 저장 (upsert 대신 간단한 방식)
$post->customFieldValues()->delete();
$boardFields = $board->fields;
foreach ($boardFields as $field) {
$value = $customFields[$field->field_key] ?? null;
if ($value !== null && $value !== '') {
PostCustomFieldValue::create([
'post_id' => $post->id,
'field_id' => $field->id,
'value' => is_array($value) ? json_encode($value) : $value,
'created_by' => auth()->id(),
]);
}
}
}
/**
* 이전/다음 글 조회
*/
public function getAdjacentPosts(Post $post): array
{
$prev = Post::query()
->ofBoard($post->board_id)
->published()
->where('id', '<', $post->id)
->orderByDesc('id')
->first(['id', 'title']);
$next = Post::query()
->ofBoard($post->board_id)
->published()
->where('id', '>', $post->id)
->orderBy('id')
->first(['id', 'title']);
return compact('prev', 'next');
}
/**
* 게시판 통계
*/
public function getBoardPostStats(int $boardId): array
{
return [
'total' => Post::ofBoard($boardId)->count(),
'published' => Post::ofBoard($boardId)->published()->count(),
'notices' => Post::ofBoard($boardId)->notices()->count(),
'today' => Post::ofBoard($boardId)->whereDate('created_at', today())->count(),
];
}
// =========================================================================
// File Management
// =========================================================================
/**
* 파일 업로드 및 게시글 연결
*/
public function uploadFiles(Post $post, array $files): array
{
$board = $post->board;
$uploadedFiles = [];
// 파일 첨부 허용 여부 확인
if (! $board->allow_files) {
throw new \Exception('이 게시판은 파일 첨부가 허용되지 않습니다.');
}
// 최대 파일 개수 확인
$currentCount = $post->files()->count();
$maxCount = $board->max_file_count;
if ($currentCount + count($files) > $maxCount) {
throw new \Exception("최대 {$maxCount}개의 파일만 첨부할 수 있습니다.");
}
$tenantId = $post->tenant_id;
$year = now()->format('Y');
$month = now()->format('m');
// Path pattern: {tenant_id}/{folder_key}/{year}/{month}/{stored_name}
// (tenant disk root already includes /tenants/)
$basePath = "{$tenantId}/posts/{$year}/{$month}";
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
continue;
}
// 파일 크기 확인 (KB 단위로 저장됨)
$fileSizeKB = $file->getSize() / 1024;
if ($fileSizeKB > $board->max_file_size) {
$maxSizeMB = round($board->max_file_size / 1024, 1);
throw new \Exception("파일 크기는 {$maxSizeMB}MB를 초과할 수 없습니다.");
}
// 저장 파일명 생성
$storedName = Str::uuid().'.'.$file->getClientOriginalExtension();
$filePath = "{$basePath}/{$storedName}";
// 파일 저장
Storage::disk('tenant')->put($filePath, file_get_contents($file));
// DB 레코드 생성
$fileRecord = File::create([
'tenant_id' => $tenantId,
'is_temp' => false,
'file_path' => $filePath,
'display_name' => $file->getClientOriginalName(),
'stored_name' => $storedName,
'original_name' => $file->getClientOriginalName(),
'file_name' => $file->getClientOriginalName(),
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'file_type' => $this->getFileType($file->getMimeType()),
'fileable_type' => Post::class,
'fileable_id' => $post->id,
'uploaded_by' => auth()->id(),
'created_by' => auth()->id(),
]);
$uploadedFiles[] = $fileRecord;
}
return $uploadedFiles;
}
/**
* 게시글 파일 삭제
*/
public function deleteFile(Post $post, int $fileId): bool
{
$file = $post->files()->find($fileId);
if (! $file) {
throw new \Exception('파일을 찾을 수 없습니다.');
}
// 물리 파일 삭제
if ($file->existsInStorage()) {
Storage::disk('tenant')->delete($file->file_path);
}
// 소프트 삭제
$file->deleted_by = auth()->id();
$file->save();
return $file->delete();
}
/**
* 게시글 파일 전체 삭제 (게시글 삭제 시)
*/
public function deleteAllFiles(Post $post): void
{
foreach ($post->files as $file) {
$file->deleted_by = auth()->id();
$file->save();
$file->delete();
}
}
/**
* 게시글 파일 영구 삭제 (게시글 영구 삭제 시)
*/
public function forceDeleteAllFiles(Post $post): void
{
foreach ($post->files()->withTrashed()->get() as $file) {
$file->permanentDelete();
}
}
/**
* 파일 타입 추출
*/
private function getFileType(?string $mimeType): string
{
if (! $mimeType) {
return 'other';
}
if (str_starts_with($mimeType, 'image/')) {
return 'image';
}
if (str_starts_with($mimeType, 'video/')) {
return 'video';
}
if (str_starts_with($mimeType, 'audio/')) {
return 'audio';
}
if (str_contains($mimeType, 'pdf')) {
return 'pdf';
}
if (str_contains($mimeType, 'word') || str_contains($mimeType, 'document')) {
return 'document';
}
if (str_contains($mimeType, 'excel') || str_contains($mimeType, 'spreadsheet')) {
return 'spreadsheet';
}
if (str_contains($mimeType, 'zip') || str_contains($mimeType, 'rar') || str_contains($mimeType, 'compressed')) {
return 'archive';
}
return 'other';
}
/**
* 게시글 파일 목록 조회
*/
public function getPostFiles(Post $post): Collection
{
return $post->files()->orderBy('created_at')->get();
}
/**
* 파일 다운로드
*/
public function downloadFile(Post $post, int $fileId)
{
$file = $post->files()->find($fileId);
if (! $file) {
abort(404, '파일을 찾을 수 없습니다.');
}
return $file->download();
}
}

View File

@@ -0,0 +1,163 @@
# 게시글 파일 첨부 기능 구현 + 공유 스토리지 설정
**작업일**: 2025-12-02
**저장소**: MNG, API, Docker
**워크플로우**: code-workflow (분석→수정→검증→정리→커밋)
---
## 개요
게시판 시스템에 파일 첨부 기능을 추가했습니다. 기존의 `board_files` 테이블 대신 범용 `files` 테이블의 polymorphic 관계를 활용합니다.
**추가 작업**: API와 MNG 간 파일 공유를 위한 Docker 공유 볼륨 설정 및 S3 마이그레이션 용이한 구조로 변경
## 변경 파일
### Docker 설정
| 파일 | 작업 | 설명 |
|------|------|------|
| `docker/docker-compose.yml` | 수정 | sam_storage 공유 볼륨 추가 (api, admin, mng) |
### API 저장소
| 파일 | 작업 | 설명 |
|------|------|------|
| `config/filesystems.php` | 수정 | tenant 디스크 공유 경로 + S3 설정 |
| `database/migrations/2025_12_02_000238_drop_board_files_table.php` | 생성 | board_files 테이블 삭제 |
### MNG 저장소
| 파일 | 작업 | 설명 |
|------|------|------|
| `app/Models/Boards/File.php` | 생성 | Polymorphic 파일 모델 |
| `app/Models/Boards/Post.php` | 수정 | files() MorphMany 관계 추가 |
| `app/Services/PostService.php` | 수정 | 파일 업로드/삭제/다운로드 + 경로 패턴 수정 |
| `app/Http/Controllers/PostController.php` | 수정 | 파일 관련 액션 추가 |
| `resources/views/posts/create.blade.php` | 수정 | 파일 업로드 UI |
| `resources/views/posts/show.blade.php` | 수정 | 첨부파일 목록 표시 |
| `resources/views/posts/edit.blade.php` | 수정 | 기존 파일 관리 + 새 파일 업로드 |
| `routes/web.php` | 수정 | 파일 라우트 추가 |
| `config/filesystems.php` | 수정 | tenant 디스크 공유 경로 + S3 설정 |
| `storage/app/tenants/.gitignore` | 생성 | 업로드 파일 Git 제외 |
## 기술 상세
### Polymorphic 관계
```php
// Post -> files() MorphMany
$post->files; // Collection of File models
// File -> fileable() MorphTo
$file->fileable; // Returns Post model
```
### 파일 저장 경로 (공유 스토리지)
```
Docker 볼륨: sam_storage → /var/www/shared-storage
실제 경로: /var/www/shared-storage/tenants/{tenant_id}/posts/{year}/{month}/{stored_name}
DB 저장: {tenant_id}/posts/{year}/{month}/{stored_name}
```
### 공유 스토리지 아키텍처
```
┌─────────────────────────────────────────────────────────────┐
│ Docker Volume: sam_storage │
│ /var/www/shared-storage/tenants │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ API │ │ MNG │ │ Admin │ │
│ │Container│ │Container│ │Container│ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └────────────────┴────────────────┘ │
│ │ │
│ Storage::disk('tenant') │
│ │ │
│ ┌─────────────────────┴─────────────────────┐ │
│ │ /var/www/shared-storage/tenants │ │
│ │ ├── {tenant_id}/ │ │
│ │ │ ├── posts/2025/12/xxx.pdf │ │
│ │ │ ├── products/2025/12/yyy.jpg │ │
│ │ │ └── documents/2025/12/zzz.docx │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### S3 마이그레이션 방법
```bash
# .env 설정 변경만으로 S3 전환 가능
TENANT_STORAGE_DRIVER=s3
AWS_ACCESS_KEY_ID=your_key
AWS_SECRET_ACCESS_KEY=your_secret
AWS_DEFAULT_REGION=ap-northeast-2
AWS_BUCKET=sam-storage
```
### 게시판 설정
- `allow_files`: 파일 첨부 허용 여부
- `max_file_count`: 최대 파일 개수
- `max_file_size`: 최대 파일 크기 (KB)
### 새 라우트
```
GET boards/{board}/posts/{post}/files/{fileId}/download # 다운로드
POST boards/{board}/posts/{post}/files # 업로드 (AJAX)
DELETE boards/{board}/posts/{post}/files/{fileId} # 삭제 (AJAX)
```
### File 모델 주요 메서드
- `fileable()`: Polymorphic 관계
- `download()`: StreamedResponse 반환
- `getFormattedSize()`: 사람이 읽기 쉬운 파일 크기
- `isImage()`: 이미지 파일 여부
- `permanentDelete()`: 실제 파일 + DB 레코드 삭제
### PostService 주요 메서드
- `uploadFiles(Post, array)`: 파일 업로드 및 저장
- `deleteFile(Post, fileId)`: 파일 소프트 삭제
- `downloadFile(Post, fileId)`: 파일 다운로드 응답
## UI 기능
### 글쓰기 (create.blade.php)
- 드래그앤드롭 파일 업로드 영역 ✅
- 파일 선택 시 미리보기 목록
- 파일 개수/크기 제한 클라이언트 검증
- **드래그앤드롭 JS 이벤트** (dragenter, dragover, dragleave, drop)
- **시각적 피드백** (드래그 시 테두리 파란색 변경)
### 글보기 (show.blade.php)
- 첨부파일 섹션 (파일 있을 때만 표시)
- 이미지/문서 아이콘 구분
- 다운로드 버튼
### 글수정 (edit.blade.php)
- 기존 첨부파일 목록 (삭제 버튼 포함)
- AJAX 파일 삭제 (확인 후 즉시 반영)
- 새 파일 추가 영역
- **드래그앤드롭 JS 이벤트** (dragenter, dragover, dragleave, drop)
- **시각적 피드백** (드래그 시 테두리 파란색 변경)
- **기존 파일 개수 고려** (최대 파일 수 체크 시 기존 파일 포함)
## 검증 결과
- [x] PHP 문법 검증 통과
- [x] 라우트 등록 확인
- [x] tenant 디스크 설정 확인
- [x] Pint 코드 포맷팅 완료
## 다음 단계 (커밋)
### API 저장소
```bash
cd /Users/kent/Works/@KD_SAM/SAM/api
git add .
git commit -m "feat(SAM-API): board_files 테이블 삭제 마이그레이션"
```
### MNG 저장소
```bash
cd /Users/kent/Works/@KD_SAM/SAM/mng
git add .
git commit -m "feat(SAM-MNG): 게시글 파일 첨부 기능 구현"
```

View File

@@ -38,6 +38,24 @@
'report' => false,
],
/*
|------------------------------------------------------------------
| Tenant Storage Disk (Shared across API/MNG/Admin)
|------------------------------------------------------------------
|
| This disk is shared across all applications via Docker volume.
| - Local (Docker): /var/www/shared-storage/tenants
| - S3: Set TENANT_STORAGE_DISK=s3 in .env
|
*/
'tenant' => [
'driver' => env('TENANT_STORAGE_DRIVER', 'local'),
'root' => env('TENANT_STORAGE_PATH', '/var/www/shared-storage/tenants'),
'visibility' => 'private',
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),

View File

@@ -14,7 +14,8 @@
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($boards as $board)
<tr class="{{ $board->trashed() ? 'bg-red-50' : '' }}">
<tr class="{{ $board->trashed() ? 'bg-red-50' : '' }} hover:bg-gray-50 cursor-pointer"
onclick="window.location='{{ route('boards.posts.index', $board->id) }}'">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $board->id }}
</td>
@@ -53,12 +54,12 @@
삭제됨
</span>
@elseif($board->is_active)
<button onclick="toggleActive({{ $board->id }})"
<button onclick="event.stopPropagation(); toggleActive({{ $board->id }})"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 hover:bg-green-200 cursor-pointer">
활성
</button>
@else
<button onclick="toggleActive({{ $board->id }})"
<button onclick="event.stopPropagation(); toggleActive({{ $board->id }})"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer">
비활성
</button>
@@ -79,8 +80,9 @@ class="text-red-600 hover:text-red-900">영구삭제</button>
@else
<!-- 일반 항목 액션 -->
<a href="{{ route('boards.edit', $board->id) }}"
onclick="event.stopPropagation()"
class="text-indigo-600 hover:text-indigo-900">수정</a>
<button onclick="confirmDelete({{ $board->id }}, '{{ $board->name }}')"
<button onclick="event.stopPropagation(); confirmDelete({{ $board->id }}, '{{ $board->name }}')"
class="text-red-600 hover:text-red-900">삭제</button>
@endif
</td>

View File

@@ -0,0 +1,296 @@
@extends('layouts.app')
@section('title', $board->name . ' - 글쓰기')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">글쓰기</h1>
<p class="text-sm text-gray-500 mt-1">{{ $board->name }}</p>
</div>
<a href="{{ route('boards.posts.index', $board) }}" class="text-gray-600 hover:text-gray-900">
&larr; 목록으로
</a>
</div>
<!-- 작성 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<form action="{{ route('boards.posts.store', $board) }}" method="POST" enctype="multipart/form-data">
@csrf
<!-- 제목 -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
제목 <span class="text-red-500">*</span>
</label>
<input type="text" name="title" id="title" value="{{ old('title') }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('title') border-red-500 @enderror"
required>
@error('title')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- 커스텀 필드들 -->
@if($fields->isNotEmpty())
<div class="mb-6 border-t border-gray-200 pt-6">
<h3 class="text-sm font-medium text-gray-700 mb-4">추가 정보</h3>
<div class="grid grid-cols-2 gap-4">
@foreach($fields as $field)
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{{ $field->name }}
@if($field->is_required)
<span class="text-red-500">*</span>
@endif
</label>
@switch($field->field_type)
@case('text')
<input type="text"
name="custom_fields[{{ $field->field_key }}]"
value="{{ old('custom_fields.' . $field->field_key) }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>
@break
@case('number')
<input type="number"
name="custom_fields[{{ $field->field_key }}]"
value="{{ old('custom_fields.' . $field->field_key) }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>
@break
@case('select')
<select name="custom_fields[{{ $field->field_key }}]"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>
<option value="">선택하세요</option>
@foreach($field->getMeta('options', []) as $option)
<option value="{{ $option }}" {{ old('custom_fields.' . $field->field_key) == $option ? 'selected' : '' }}>
{{ $option }}
</option>
@endforeach
</select>
@break
@case('date')
<input type="date"
name="custom_fields[{{ $field->field_key }}]"
value="{{ old('custom_fields.' . $field->field_key) }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>
@break
@case('textarea')
<textarea name="custom_fields[{{ $field->field_key }}]"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>{{ old('custom_fields.' . $field->field_key) }}</textarea>
@break
@case('checkbox')
<label class="flex items-center">
<input type="checkbox"
name="custom_fields[{{ $field->field_key }}]"
value="1"
{{ old('custom_fields.' . $field->field_key) ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-600">{{ $field->getMeta('label', '예') }}</span>
</label>
@break
@default
<input type="text"
name="custom_fields[{{ $field->field_key }}]"
value="{{ old('custom_fields.' . $field->field_key) }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
@endswitch
@error('custom_fields.' . $field->field_key)
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endforeach
</div>
</div>
@endif
<!-- 내용 -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">내용</label>
<textarea name="content" id="content" rows="15"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('content') border-red-500 @enderror">{{ old('content') }}</textarea>
@error('content')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- 파일 첨부 -->
@if($board->allow_files)
<div class="mb-6 border-t border-gray-200 pt-6">
<label class="block text-sm font-medium text-gray-700 mb-2">
파일 첨부
<span class="text-gray-400 font-normal">(최대 {{ $board->max_file_count }}, 파일당 {{ round($board->max_file_size / 1024, 1) }}MB)</span>
</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6">
<input type="file" name="files[]" id="files" multiple
class="hidden"
accept="*/*">
<label for="files" class="cursor-pointer block text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="mt-2 text-sm text-gray-600">
<span class="text-blue-600 hover:text-blue-500">파일을 선택</span>하거나 여기로 드래그하세요
</p>
</label>
<div id="file-list" class="mt-4 space-y-2"></div>
</div>
@error('files')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
@error('files.*')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endif
<!-- 옵션 -->
<div class="mb-6 flex items-center gap-6">
@if($board->getSetting('allow_secret', true))
<label class="flex items-center">
<input type="checkbox" name="is_secret" value="1" {{ old('is_secret') ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">비밀글</span>
</label>
@endif
@if(auth()->user()->hasRole(['admin', 'super-admin', 'manager']))
<label class="flex items-center">
<input type="checkbox" name="is_notice" value="1" {{ old('is_notice') ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">공지사항으로 등록</span>
</label>
@endif
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-3">
<a href="{{ route('boards.posts.index', $board) }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
등록
</button>
</div>
</form>
</div>
@if($board->allow_files)
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const dropZone = fileInput?.closest('.border-dashed');
const maxFiles = {{ $board->max_file_count }};
const maxSize = {{ $board->max_file_size * 1024 }}; // bytes
// 파일 목록 표시 함수
function displayFiles(files) {
fileList.innerHTML = '';
if (files.length > maxFiles) {
alert(`최대 ${maxFiles}개의 파일만 첨부할 수 있습니다.`);
fileInput.value = '';
return;
}
Array.from(files).forEach((file, index) => {
if (file.size > maxSize) {
alert(`${file.name}: 파일 크기가 너무 큽니다.`);
return;
}
const item = document.createElement('div');
item.className = 'flex items-center justify-between px-3 py-2 bg-blue-50 rounded-lg';
item.innerHTML = `
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-blue-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>
<span class="text-sm text-gray-700">${file.name}</span>
<span class="text-xs text-gray-400">(${formatFileSize(file.size)})</span>
</div>
`;
fileList.appendChild(item);
});
}
// 파일 선택 이벤트
if (fileInput) {
fileInput.addEventListener('change', function() {
displayFiles(this.files);
});
}
// 드래그앤드롭 이벤트
if (dropZone) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, unhighlight, false);
});
function highlight() {
dropZone.classList.add('border-blue-500', 'bg-blue-50');
dropZone.classList.remove('border-gray-300');
}
function unhighlight() {
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
dropZone.classList.add('border-gray-300');
}
dropZone.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
// DataTransfer를 사용하여 file input에 파일 설정
const dataTransfer = new DataTransfer();
Array.from(files).forEach(file => dataTransfer.items.add(file));
fileInput.files = dataTransfer.files;
displayFiles(files);
}
}
function formatFileSize(bytes) {
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB';
return bytes + ' bytes';
}
});
</script>
@endpush
@endif
@endsection

View File

@@ -0,0 +1,374 @@
@extends('layouts.app')
@section('title', '게시글 수정')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">게시글 수정</h1>
<p class="text-sm text-gray-500 mt-1">{{ $board->name }}</p>
</div>
<a href="{{ route('boards.posts.show', [$board, $post]) }}" class="text-gray-600 hover:text-gray-900">
&larr; 돌아가기
</a>
</div>
<!-- 수정 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<form action="{{ route('boards.posts.update', [$board, $post]) }}" method="POST" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- 제목 -->
<div class="mb-6">
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
제목 <span class="text-red-500">*</span>
</label>
<input type="text" name="title" id="title" value="{{ old('title', $post->title) }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('title') border-red-500 @enderror"
required>
@error('title')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- 커스텀 필드들 -->
@if($fields->isNotEmpty())
<div class="mb-6 border-t border-gray-200 pt-6">
<h3 class="text-sm font-medium text-gray-700 mb-4">추가 정보</h3>
<div class="grid grid-cols-2 gap-4">
@foreach($fields as $field)
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{{ $field->name }}
@if($field->is_required)
<span class="text-red-500">*</span>
@endif
</label>
@php
$fieldValue = old('custom_fields.' . $field->field_key, $customFieldValues[$field->field_key] ?? '');
@endphp
@switch($field->field_type)
@case('text')
<input type="text"
name="custom_fields[{{ $field->field_key }}]"
value="{{ $fieldValue }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>
@break
@case('number')
<input type="number"
name="custom_fields[{{ $field->field_key }}]"
value="{{ $fieldValue }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>
@break
@case('select')
<select name="custom_fields[{{ $field->field_key }}]"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>
<option value="">선택하세요</option>
@foreach($field->getMeta('options', []) as $option)
<option value="{{ $option }}" {{ $fieldValue == $option ? 'selected' : '' }}>
{{ $option }}
</option>
@endforeach
</select>
@break
@case('date')
<input type="date"
name="custom_fields[{{ $field->field_key }}]"
value="{{ $fieldValue }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>
@break
@case('textarea')
<textarea name="custom_fields[{{ $field->field_key }}]"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ $field->is_required ? 'required' : '' }}>{{ $fieldValue }}</textarea>
@break
@case('checkbox')
<label class="flex items-center">
<input type="checkbox"
name="custom_fields[{{ $field->field_key }}]"
value="1"
{{ $fieldValue ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-600">{{ $field->getMeta('label', '예') }}</span>
</label>
@break
@default
<input type="text"
name="custom_fields[{{ $field->field_key }}]"
value="{{ $fieldValue }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
@endswitch
@error('custom_fields.' . $field->field_key)
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endforeach
</div>
</div>
@endif
<!-- 내용 -->
<div class="mb-6">
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">내용</label>
<textarea name="content" id="content" rows="15"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('content') border-red-500 @enderror">{{ old('content', $post->content) }}</textarea>
@error('content')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- 기존 첨부파일 -->
@if($board->allow_files && $post->files->isNotEmpty())
<div class="mb-6 border-t border-gray-200 pt-6">
<label class="block text-sm font-medium text-gray-700 mb-2">
기존 첨부파일 ({{ $post->files->count() }})
</label>
<div id="existing-files" class="space-y-2">
@foreach($post->files as $file)
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 rounded-lg" data-file-id="{{ $file->id }}">
<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
<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>
</div>
</div>
<button type="button"
onclick="deleteFile({{ $file->id }})"
class="px-3 py-1 text-sm text-red-600 hover:text-red-800 border border-red-300 rounded hover:bg-red-50 transition">
삭제
</button>
</div>
@endforeach
</div>
</div>
@endif
<!-- 파일 첨부 -->
@if($board->allow_files)
<div class="mb-6 border-t border-gray-200 pt-6">
<label class="block text-sm font-medium text-gray-700 mb-2">
파일 추가
<span class="text-gray-400 font-normal">(최대 {{ $board->max_file_count }}, 파일당 {{ round($board->max_file_size / 1024, 1) }}MB)</span>
</label>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6">
<input type="file" name="files[]" id="files" multiple
class="hidden"
accept="*/*">
<label for="files" class="cursor-pointer block text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="mt-2 text-sm text-gray-600">
<span class="text-blue-600 hover:text-blue-500">파일을 선택</span>하거나 여기로 드래그하세요
</p>
</label>
<div id="file-list" class="mt-4 space-y-2"></div>
</div>
@error('files')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
@error('files.*')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
@endif
<!-- 옵션 -->
<div class="mb-6 flex items-center gap-6">
@if($board->getSetting('allow_secret', true))
<label class="flex items-center">
<input type="checkbox" name="is_secret" value="1" {{ old('is_secret', $post->is_secret) ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">비밀글</span>
</label>
@endif
@if(auth()->user()->hasRole(['admin', 'super-admin', 'manager']))
<label class="flex items-center">
<input type="checkbox" name="is_notice" value="1" {{ old('is_notice', $post->is_notice) ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">공지사항으로 등록</span>
</label>
@endif
</div>
<!-- 버튼 -->
<div class="flex justify-end gap-3">
<a href="{{ route('boards.posts.show', [$board, $post]) }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
수정
</button>
</div>
</form>
</div>
@if($board->allow_files)
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const dropZone = fileInput?.closest('.border-dashed');
const existingCount = {{ $post->files->count() }};
const maxFiles = {{ $board->max_file_count }};
const maxSize = {{ $board->max_file_size * 1024 }}; // bytes
// 파일 목록 표시 함수
function displayFiles(files) {
fileList.innerHTML = '';
const currentExisting = document.querySelectorAll('#existing-files > div').length;
if (files.length + currentExisting > maxFiles) {
alert(`최대 ${maxFiles}개의 파일만 첨부할 수 있습니다. (기존 ${currentExisting}개 포함)`);
fileInput.value = '';
return;
}
Array.from(files).forEach((file, index) => {
if (file.size > maxSize) {
alert(`${file.name}: 파일 크기가 너무 큽니다.`);
return;
}
const item = document.createElement('div');
item.className = 'flex items-center justify-between px-3 py-2 bg-blue-50 rounded-lg';
item.innerHTML = `
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-blue-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>
<span class="text-sm text-gray-700">${file.name}</span>
<span class="text-xs text-gray-400">(${formatFileSize(file.size)})</span>
<span class="text-xs text-blue-500">(새 파일)</span>
</div>
`;
fileList.appendChild(item);
});
}
// 파일 선택 이벤트
if (fileInput) {
fileInput.addEventListener('change', function() {
displayFiles(this.files);
});
}
// 드래그앤드롭 이벤트
if (dropZone) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, unhighlight, false);
});
function highlight() {
dropZone.classList.add('border-blue-500', 'bg-blue-50');
dropZone.classList.remove('border-gray-300');
}
function unhighlight() {
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
dropZone.classList.add('border-gray-300');
}
dropZone.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
// DataTransfer를 사용하여 file input에 파일 설정
const dataTransfer = new DataTransfer();
Array.from(files).forEach(file => dataTransfer.items.add(file));
fileInput.files = dataTransfer.files;
displayFiles(files);
}
}
function formatFileSize(bytes) {
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB';
return bytes + ' bytes';
}
});
function deleteFile(fileId) {
if (!confirm('이 파일을 삭제하시겠습니까?')) return;
const url = '{{ route("boards.posts.files.delete", [$board, $post, ":fileId"]) }}'.replace(':fileId', fileId);
fetch(url, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const fileElement = document.querySelector(`[data-file-id="${fileId}"]`);
if (fileElement) {
fileElement.remove();
}
// 남은 파일이 없으면 섹션 숨김
const existingFiles = document.getElementById('existing-files');
if (existingFiles && existingFiles.children.length === 0) {
existingFiles.closest('.mb-6').remove();
}
} else {
alert(data.message || '파일 삭제에 실패했습니다.');
}
})
.catch(error => {
console.error('Error:', error);
alert('파일 삭제 중 오류가 발생했습니다.');
});
}
</script>
@endpush
@endif
@endsection

View File

@@ -0,0 +1,154 @@
@extends('layouts.app')
@section('title', $board->name)
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">{{ $board->name }}</h1>
@if($board->description)
<p class="text-sm text-gray-500 mt-1">{{ $board->description }}</p>
@endif
</div>
<div class="flex items-center gap-3">
<a href="{{ route('boards.posts.create', $board) }}"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
글쓰기
</a>
<a href="{{ route('boards.index') }}"
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium transition flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
게시판 목록
</a>
</div>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">전체 게시글</div>
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['total']) }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">공지사항</div>
<div class="text-2xl font-bold text-blue-600">{{ number_format($stats['notices']) }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">오늘 작성</div>
<div class="text-2xl font-bold text-green-600">{{ number_format($stats['today']) }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">게시 </div>
<div class="text-2xl font-bold text-gray-600">{{ number_format($stats['published']) }}</div>
</div>
</div>
<!-- 검색 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form action="{{ route('boards.posts.index', $board) }}" method="GET" class="flex items-center gap-4">
<div class="flex-1">
<input type="text" name="search" value="{{ $filters['search'] ?? '' }}"
placeholder="제목, 내용 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
검색
</button>
@if(!empty($filters['search']))
<a href="{{ route('boards.posts.index', $board) }}" class="px-4 py-2 text-gray-500 hover:text-gray-700">
초기화
</a>
@endif
</form>
</div>
<!-- 게시글 목록 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">번호</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">제목</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-32">작성자</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-32">작성일</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">조회</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($posts as $post)
<tr class="hover:bg-gray-50 {{ $post->is_notice ? 'bg-blue-50' : '' }}">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@if($post->is_notice)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
공지
</span>
@else
{{ $post->id }}
@endif
</td>
<td class="px-6 py-4">
<a href="{{ route('boards.posts.show', [$board, $post]) }}"
class="text-gray-900 hover:text-blue-600 font-medium">
@if($post->is_secret)
<svg class="w-4 h-4 inline-block text-gray-400 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
@endif
{{ $post->title }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $post->author?->name ?? '알 수 없음' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $post->created_at->format('Y-m-d') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ number_format($post->views) }}
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-12 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" 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>
<p>게시글이 없습니다.</p>
<a href="{{ route('boards.posts.create', $board) }}" class="mt-2 inline-block text-blue-600 hover:text-blue-800">
게시글 작성하기
</a>
</td>
</tr>
@endforelse
</tbody>
</table>
<!-- 페이지네이션 -->
@if($posts->hasPages())
<div class="px-6 py-4 border-t border-gray-200">
{{ $posts->withQueryString()->links() }}
</div>
@endif
</div>
<!-- 알림 메시지 -->
@if(session('success'))
<div class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg" id="toast">
{{ session('success') }}
</div>
<script>setTimeout(() => document.getElementById('toast')?.remove(), 3000);</script>
@endif
@if(session('error'))
<div class="fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg" id="toast-error">
{{ session('error') }}
</div>
<script>setTimeout(() => document.getElementById('toast-error')?.remove(), 3000);</script>
@endif
@endsection

View File

@@ -0,0 +1,165 @@
@extends('layouts.app')
@section('title', $post->title)
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<div>
<p class="text-sm text-gray-500">{{ $board->name }}</p>
<h1 class="text-2xl font-bold text-gray-800 mt-1">
@if($post->is_notice)
<span class="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium bg-blue-100 text-blue-800 mr-2">
공지
</span>
@endif
@if($post->is_secret)
<svg class="w-5 h-5 inline-block text-gray-400 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
@endif
{{ $post->title }}
</h1>
</div>
<a href="{{ route('boards.posts.index', $board) }}" class="text-gray-600 hover:text-gray-900">
&larr; 목록으로
</a>
</div>
<!-- 게시글 정보 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 메타 정보 -->
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<div class="flex items-center justify-between text-sm text-gray-500">
<div class="flex items-center gap-6">
<span>
<span class="font-medium text-gray-700">작성자:</span>
{{ $post->author?->name ?? '알 수 없음' }}
</span>
<span>
<span class="font-medium text-gray-700">작성일:</span>
{{ $post->created_at->format('Y-m-d H:i') }}
</span>
<span>
<span class="font-medium text-gray-700">조회:</span>
{{ number_format($post->views) }}
</span>
</div>
@if($post->user_id === auth()->id() || auth()->user()->hasRole(['admin', 'super-admin']))
<div class="flex items-center gap-2">
<a href="{{ route('boards.posts.edit', [$board, $post]) }}"
class="px-3 py-1 text-sm text-blue-600 hover:text-blue-800 border border-blue-300 rounded hover:bg-blue-50 transition">
수정
</a>
<form action="{{ route('boards.posts.destroy', [$board, $post]) }}" method="POST"
onsubmit="return confirm('정말 삭제하시겠습니까?');">
@csrf
@method('DELETE')
<button type="submit"
class="px-3 py-1 text-sm text-red-600 hover:text-red-800 border border-red-300 rounded hover:bg-red-50 transition">
삭제
</button>
</form>
</div>
@endif
</div>
</div>
<!-- 커스텀 필드 -->
@if(!empty($customFieldValues))
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<div class="grid grid-cols-3 gap-4 text-sm">
@foreach($board->fields as $field)
@if(isset($customFieldValues[$field->field_key]))
<div>
<span class="font-medium text-gray-700">{{ $field->name }}:</span>
<span class="text-gray-600 ml-1">{{ $customFieldValues[$field->field_key] }}</span>
</div>
@endif
@endforeach
</div>
</div>
@endif
<!-- 본문 -->
<div class="px-6 py-8">
<div class="prose max-w-none">
{!! nl2br(e($post->content)) !!}
</div>
</div>
<!-- 첨부파일 -->
@if($post->files->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() }})
</h3>
<div class="space-y-2">
@foreach($post->files 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
<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>
</div>
</div>
<a href="{{ route('boards.posts.files.download', [$board, $post, $file->id]) }}"
class="px-3 py-1 text-sm text-blue-600 hover:text-blue-800 border border-blue-300 rounded hover:bg-blue-50 transition">
다운로드
</a>
</div>
@endforeach
</div>
</div>
@endif
</div>
<!-- 이전/다음 네비게이션 -->
<div class="mt-6 bg-white rounded-lg shadow-sm overflow-hidden">
<div class="divide-y divide-gray-200">
@if($adjacent['prev'])
<a href="{{ route('boards.posts.show', [$board, $adjacent['prev']->id]) }}"
class="block px-6 py-4 hover:bg-gray-50 transition">
<div class="flex items-center">
<span class="text-sm text-gray-500 w-20">이전글</span>
<span class="text-gray-900">{{ $adjacent['prev']->title }}</span>
</div>
</a>
@endif
@if($adjacent['next'])
<a href="{{ route('boards.posts.show', [$board, $adjacent['next']->id]) }}"
class="block px-6 py-4 hover:bg-gray-50 transition">
<div class="flex items-center">
<span class="text-sm text-gray-500 w-20">다음글</span>
<span class="text-gray-900">{{ $adjacent['next']->title }}</span>
</div>
</a>
@endif
</div>
</div>
<!-- 목록 버튼 -->
<div class="mt-6 flex justify-center">
<a href="{{ route('boards.posts.index', $board) }}"
class="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
목록
</a>
</div>
<!-- 알림 메시지 -->
@if(session('success'))
<div class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg" id="toast">
{{ session('success') }}
</div>
<script>setTimeout(() => document.getElementById('toast')?.remove(), 3000);</script>
@endif
@endsection

View File

@@ -106,6 +106,13 @@
Route::get('/{post}/edit', [PostController::class, 'edit'])->name('edit');
Route::put('/{post}', [PostController::class, 'update'])->name('update');
Route::delete('/{post}', [PostController::class, 'destroy'])->name('destroy');
// 파일 관리
Route::prefix('{post}/files')->name('files.')->group(function () {
Route::get('/{fileId}/download', [PostController::class, 'downloadFile'])->name('download');
Route::post('/', [PostController::class, 'uploadFiles'])->name('upload');
Route::delete('/{fileId}', [PostController::class, 'deleteFile'])->name('delete');
});
});
});