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:
374
resources/views/posts/edit.blade.php
Normal file
374
resources/views/posts/edit.blade.php
Normal 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">
|
||||
← 돌아가기
|
||||
</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
|
||||
Reference in New Issue
Block a user